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 Melder(Func>? auszug = 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, auszug ?? (_ => [])); } private static SupervisionEvent Ereignis( 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 Ein_Absturz_geht_raus() { (await Melder().ReportAsync(Ereignis(InstanceEventKind.Crashed, exitCode: 137))) .ShouldBeTrue(); _handler.Calls.ShouldBe(1); var rumpf = _handler.LastBody.ShouldNotBeNull(); rumpf.ShouldContain("unerwartet beendet"); rumpf.ShouldContain("4711"); rumpf.ShouldContain("137"); rumpf.ShouldContain("instanzmanager"); rumpf.ShouldContain("clawddotnet"); // project binding rumpf.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 Alltag_wird_nicht_gemeldet(InstanceEventKind kind) { (await Melder().ReportAsync(Ereignis(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 Dieselbe_Instanz_und_dieselbe_Art_ergeben_denselben_Schluessel() { var melder = Melder(); await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed)); var erster = SchluesselAus(_handler.LastBody!); await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed)); SchluesselAus(_handler.LastBody!).ShouldBe(erster); // A different kind is a different finding. await melder.ReportAsync(Ereignis(InstanceEventKind.Unresponsive)); SchluesselAus(_handler.LastBody!).ShouldNotBe(erster); // And a different instance all the more. await melder.ReportAsync( Ereignis(InstanceEventKind.Crashed, dir: @"C:\Instanzen\Instance-Beta")); SchluesselAus(_handler.LastBody!).ShouldNotBe(erster); } [Fact] public async Task Der_Protokollauszug_kommt_mit() { await Melder(_ => ["Startup.log: [17:26:50] [INF] Application is shutting down"]) .ReportAsync(Ereignis(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 Ein_leerer_Protokollauszug_wird_ausdruecklich_gesagt() { await Melder(_ => []).ReportAsync(Ereignis(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 Ein_Serverfehler_reisst_die_Aufsicht_nicht_mit() { 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 melder = new SupervisionReporter( new BugtrackerClient(api, "clawddotnet", "production", "0.1.2"), NullLogger.Instance, _ => []); (await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed))).ShouldBeFalse(); } private static string SchluesselAus(string body) { using var doc = System.Text.Json.JsonDocument.Parse(body); return doc.RootElement.GetProperty("client_ref").GetString()!; } }