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()!;
}
}