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_goes_to_the_v1_path_with_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.ShouldNotBeNull().ShouldContain("\"source\":\"clawddotnet\""); handler.LastBody.ShouldNotBeNull().ShouldContain("\"instance\":\"inst-1\""); handler.LastBody.ShouldNotBeNull().ShouldContain("\"type\":\"heartbeat\""); handler.LastBody.ShouldNotBeNull().ShouldContain("\"interval\":60"); } /// /// The server stores the version in watchdog_monitors.app_version. With /// several instances that is the difference between "runs" and "still runs on /// the old version". /// [Fact] public async Task Heartbeat_carries_the_application_version() { var handler = new CapturingHandler(); await Client(handler).SendHeartbeatAsync(Health(), 60, default); handler.LastBody!.ShouldContain("\"version\":\"0.1.0\""); } /// /// The monitor is tracked server-side via source + instance. Two instances /// of the same application must therefore report different instance values — /// otherwise they overwrite each other, and the failure of one would stay invisible. /// [Fact] public async Task Every_instance_reports_under_its_own_instance_name() { 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_and_metrics_go_along() { 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.ShouldNotBeNull().ShouldContain("\"scheduler\":{\"ok\":false,\"message\":\"Steht.\"}"); } /// Empty details are left out instead of appearing as an empty object. [Fact] public async Task Without_checks_and_metrics_the_fields_are_left_out() { var handler = new CapturingHandler(); await Client(handler).SendHeartbeatAsync(Health(), 60, default); handler.LastBody!.ShouldNotContain("\"checks\""); handler.LastBody.ShouldNotBeNull().ShouldNotContain("\"metrics\""); } [Fact] public async Task Answer_returns_state_and_failed_checks() { 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 Event_goes_to_the_event_endpoint() { 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 Rejected_token_throws_with_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"); } }