using System.Net; using System.Text; using ClawdDotNet.Core.Deploymentcenter; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Manager.Tests; /// Captures the last request and answers with a fixed response, without network access. internal sealed class CapturingHandler( HttpStatusCode status = HttpStatusCode.OK, string responseBody = """{"status":"success","item_id":1,"is_new":true,"occurrence_count":1}""") : HttpMessageHandler { public string? LastBody { get; private set; } public int Calls { get; private set; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Calls++; if (request.Content is not null) LastBody = await request.Content.ReadAsStringAsync(cancellationToken); return new HttpResponseMessage(status) { Content = new StringContent(responseBody, Encoding.UTF8, "application/json") }; } } /// /// The report on the instance's behalf (#528, concept section 4). /// /// On 2026-09-08 an instance hung and was ended by Windows: /// no log line, no error message, no bug tracker entry. Inherent in the design — /// reporting path and watchdog lived in the process they were supposed to report on. The /// manager therefore reports in their place, and only what is relevant from its point of view. /// public sealed class SupervisionReporterTests { private readonly CapturingHandler _handler = new(); private SupervisionReporter Reporter(Func>? excerpt = null) { var api = new DeploymentcenterApi("https://dc.example", "t", new HttpClient(_handler)); var bugtracker = new BugtrackerClient(api, "clawddotnet", "production", "0.1.2"); return new SupervisionReporter(bugtracker, NullLogger.Instance, excerpt ?? (_ => [])); } private static SupervisionEvent Event( InstanceEventKind kind, int? exitCode = null, string dir = @"C:\Instanzen\Instance-Alpha") => new(kind, dir, "Alpha", 4711, exitCode, "Instanz Alpha ist unerwartet beendet worden.", DateTimeOffset.Parse("2026-09-08T17:27:45Z")); [Fact] public async Task A_crash_goes_out() { (await Reporter().ReportAsync(Event(InstanceEventKind.Crashed, exitCode: 137))) .ShouldBeTrue(); _handler.Calls.ShouldBe(1); var body = _handler.LastBody.ShouldNotBeNull(); body.ShouldContain("unerwartet beendet"); body.ShouldContain("4711"); body.ShouldContain("137"); body.ShouldContain("instanzmanager"); body.ShouldContain("clawddotnet"); // project binding body.ShouldContain("0.1.2"); // build_version } /// /// What concerns the instance stays in the instance — it reports it itself, with its /// own sub-token. The manager only reports what nobody ordered. /// [Theory] [InlineData(InstanceEventKind.Started)] [InlineData(InstanceEventKind.StoppedOrderly)] [InlineData(InstanceEventKind.Recovered)] public async Task Routine_is_not_reported(InstanceEventKind kind) { (await Reporter().ReportAsync(Event(kind))).ShouldBeFalse(); _handler.Calls.ShouldBe(0); } /// /// If the same instance crashes repeatedly, the server should count up instead of filling the /// bug tracker with twins — repeated crashes are one finding and /// not a dozen identical ones. /// [Fact] public async Task The_same_instance_and_kind_give_the_same_key() { var reporter = Reporter(); await reporter.ReportAsync(Event(InstanceEventKind.Crashed)); var firstOne = KeyFrom(_handler.LastBody!); await reporter.ReportAsync(Event(InstanceEventKind.Crashed)); KeyFrom(_handler.LastBody!).ShouldBe(firstOne); // A different kind is a different finding. await reporter.ReportAsync(Event(InstanceEventKind.Unresponsive)); KeyFrom(_handler.LastBody!).ShouldNotBe(firstOne); // And a different instance all the more. await reporter.ReportAsync( Event(InstanceEventKind.Crashed, dir: @"C:\Instanzen\Instance-Beta")); KeyFrom(_handler.LastBody!).ShouldNotBe(firstOne); } [Fact] public async Task The_log_excerpt_comes_along() { await Reporter(_ => ["Startup.log: [17:26:50] [INF] Application is shutting down"]) .ReportAsync(Event(InstanceEventKind.Crashed)); _handler.LastBody.ShouldNotBeNull().ShouldContain("Application is shutting down"); } /// /// An empty excerpt is the most meaningful line of the whole report: a process /// that was killed before its write buffer was flushed leaves nothing. /// Exactly that was the state from #529. /// [Fact] public async Task An_empty_log_excerpt_is_stated_explicitly() { await Reporter(_ => []).ReportAsync(Event(InstanceEventKind.Crashed)); _handler.LastBody.ShouldNotBeNull().ShouldContain("KEINE"); } /// /// A reporting path that throws itself would be the worst of all worlds: the /// original finding would get lost, and the supervision would die of its /// own report. /// [Fact] public async Task A_server_error_does_not_take_supervision_down() { var handler = new CapturingHandler(HttpStatusCode.InternalServerError, """{"status":"error","error":{"code":"boom","message":"kaputt"}}"""); var api = new DeploymentcenterApi("https://dc.example", "t", new HttpClient(handler)); var reporter = new SupervisionReporter( new BugtrackerClient(api, "clawddotnet", "production", "0.1.2"), NullLogger.Instance, _ => []); (await reporter.ReportAsync(Event(InstanceEventKind.Crashed))).ShouldBeFalse(); } private static string KeyFrom(string body) { using var doc = System.Text.Json.JsonDocument.Parse(body); return doc.RootElement.GetProperty("client_ref").GetString()!; } }