using System.Net; using ClawdDotNet.Core.Deploymentcenter; using ClawdDotNet.Core.Deploymentcenter.Watchdog; using Shouldly; namespace ClawdDotNet.Core.Tests.Deploymentcenter; public sealed class WatchdogClientTests { private static WatchdogClient Client(HttpMessageHandler handler, string instance = "inst-1") => WatchdogClient.Create("https://dc.example", "tok", "clawddotnet", instance, "ClawdDotNet", "Windows / .NET 10", "0.1.0", new HttpClient(handler)); private static InstanceHealth Health( string status = WatchdogStatus.Ok, string? message = null, Dictionary? metrics = null, Dictionary? checks = null) => new(status, message, metrics ?? [], checks ?? []); [Fact] public async Task Heartbeat_geht_an_den_v1_Pfad_mit_Bearer_Token() { var handler = new CapturingHandler(); await Client(handler).SendHeartbeatAsync(Health(message: "Betrieb normal."), 60, default); handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/watchdog/v1/ping"); handler.LastRequest.Method.ShouldBe(HttpMethod.Post); handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer tok"); handler.LastBody.ShouldNotBeNull(); handler.LastBody.ShouldContain("\"source\":\"clawddotnet\""); handler.LastBody.ShouldContain("\"instance\":\"inst-1\""); handler.LastBody.ShouldContain("\"type\":\"heartbeat\""); handler.LastBody.ShouldContain("\"interval\":60"); } /// /// Der Server legt die Version in watchdog_monitors.app_version ab. Bei /// mehreren Instanzen ist das der Unterschied zwischen „läuft" und „läuft noch auf /// der alten Fassung". /// [Fact] public async Task Heartbeat_traegt_die_Anwendungsversion() { var handler = new CapturingHandler(); await Client(handler).SendHeartbeatAsync(Health(), 60, default); handler.LastBody!.ShouldContain("\"version\":\"0.1.0\""); } /// /// Der Monitor wird serverseitig über source + instance geführt. Zwei Instanzen /// derselben Anwendung müssen deshalb unterschiedliche instance-Werte melden — /// sonst überschreiben sie sich gegenseitig, und der Ausfall einer bliebe unsichtbar. /// [Fact] public async Task Jede_Instanz_meldet_unter_eigenem_Instanznamen() { var first = new CapturingHandler(); var second = new CapturingHandler(); await Client(first, "inst-a").SendHeartbeatAsync(Health(), 60, default); await Client(second, "inst-b").SendHeartbeatAsync(Health(), 60, default); first.LastBody!.ShouldContain("\"instance\":\"inst-a\""); second.LastBody!.ShouldContain("\"instance\":\"inst-b\""); } [Fact] public async Task Checks_und_Metriken_gehen_mit() { var handler = new CapturingHandler(); await Client(handler).SendHeartbeatAsync( Health( metrics: new Dictionary { ["agentCount"] = 3 }, checks: new Dictionary { ["scheduler"] = new(false, "Steht.") }), 60, default); handler.LastBody!.ShouldContain("\"agentCount\":3"); handler.LastBody.ShouldContain("\"scheduler\":{\"ok\":false,\"message\":\"Steht.\"}"); } /// Leere Angaben bleiben weg, statt als leeres Objekt zu erscheinen. [Fact] public async Task Ohne_Checks_und_Metriken_bleiben_die_Felder_weg() { var handler = new CapturingHandler(); await Client(handler).SendHeartbeatAsync(Health(), 60, default); handler.LastBody!.ShouldNotContain("\"checks\""); handler.LastBody.ShouldNotContain("\"metrics\""); } [Fact] public async Task Antwort_liefert_Zustand_und_fehlgeschlagene_Pruefungen() { var handler = new CapturingHandler(HttpStatusCode.OK, """{"status":"success","monitor":{"state":"warning","failing_checks":["scheduler"]}}"""); var result = await Client(handler).SendHeartbeatAsync(Health(), 60, default); result.State.ShouldBe("warning"); result.FailingChecks.ShouldHaveSingleItem().ShouldBe("scheduler"); } [Fact] public async Task Ereignis_geht_an_den_Event_Endpunkt() { var handler = new CapturingHandler(); await Client(handler).SendEventAsync( WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, default); handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/watchdog/v1/event"); handler.LastBody!.ShouldContain("\"kind\":\"stopped_graceful\""); } [Fact] public async Task Abgelehntes_Token_wirft_mit_Code() { var handler = new CapturingHandler(HttpStatusCode.Unauthorized, """{"status":"error","error":{"code":"unauthorized","message":"Kein gueltiges Token."}}"""); var ex = await Should.ThrowAsync( () => Client(handler).SendHeartbeatAsync(Health(), 60, default)); ex.Code.ShouldBe("unauthorized"); } }