using System.Net; using System.Text; using ClawdDotNet.Core.Deploymentcenter; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Manager.Tests; /// Fängt die letzte Anfrage ab und antwortet fest, ohne Netzwerkzugriff. 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") }; } } /// /// Die stellvertretende Meldung (#528, Konzept Abschnitt 4). /// /// Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von Windows beendet: /// keine Protokollzeile, keine Fehlermeldung, kein Bugtracker-Eintrag. Bauartbedingt — /// Meldeweg und Watchdog lebten in dem Prozess, über den sie berichten sollten. Der /// Manager meldet deshalb an ihrer Stelle, und zwar nur das, was aus seiner Sicht /// relevant ist. /// 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"); // Projektbindung rumpf.ShouldContain("0.1.2"); // build_version } /// /// Was die Instanz betrifft, bleibt in der Instanz — sie meldet es selbst, mit ihrem /// eigenen Sub-Token. Der Manager meldet nur, was niemand bestellt hat. /// [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); } /// /// Stürzt dieselbe Instanz wiederholt ab, soll der Server hochzählen statt den /// Bugtracker mit Zwillingen zu füllen — wiederholte Abstürze sind ein Befund und /// nicht ein Dutzend gleicher. /// [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); // Eine andere Art ist ein anderer Befund. await melder.ReportAsync(Ereignis(InstanceEventKind.Unresponsive)); SchluesselAus(_handler.LastBody!).ShouldNotBe(erster); // Und eine andere Instanz erst recht. 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"); } /// /// Ein leerer Auszug ist die aussagekräftigste Zeile der ganzen Meldung: Ein Prozess, /// der erschlagen wurde, bevor sein Schreibpuffer geleert war, hinterlässt nichts. /// Genau das war der Zustand aus #529. /// [Fact] public async Task Ein_leerer_Protokollauszug_wird_ausdruecklich_gesagt() { await Melder(_ => []).ReportAsync(Ereignis(InstanceEventKind.Crashed)); _handler.LastBody.ShouldNotBeNull().ShouldContain("KEINE"); } /// /// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten: Der /// ursprüngliche Befund ginge dabei verloren, und die Aufsicht stürbe an ihrem /// eigenen Bericht. /// [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()!; } }