using System.Net; using System.Net.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Server.Hosting; using ZB.MOM.WW.OtOpcUa.Server.Redundancy; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// /// Unit tests for . Drives TickAsync synchronously /// via a test double so we don't race the loop's /// Task.Delay. /// [Trait("Category", "Unit")] public sealed class PeerHttpProbeLoopTests : IDisposable { private readonly OtOpcUaConfigDbContext _db; private readonly IDbContextFactory _dbFactory; public PeerHttpProbeLoopTests() { var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase($"peer-http-{Guid.NewGuid():N}") .Options; _db = new OtOpcUaConfigDbContext(opts); _dbFactory = new DbContextFactory(opts); } public void Dispose() => _db.Dispose(); [Fact] public async Task Tick_with_no_peers_is_a_no_op() { var tracker = new PeerReachabilityTracker(); var coordinator = await SeedAndInitializeAsync("A", ("A", RedundancyRole.Primary, "urn:A")); var loop = new PeerHttpProbeLoop(coordinator, tracker, new StubHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.OK)), NullLogger.Instance); await loop.TickAsync(CancellationToken.None); tracker.Get("B").ShouldBe(PeerReachability.Unknown); } [Fact] public async Task Tick_marks_peer_healthy_when_healthz_returns_200() { var coordinator = await SeedAndInitializeAsync("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var tracker = new PeerReachabilityTracker(); var factory = new StubHttpClientFactory(req => { req.RequestUri!.AbsolutePath.ShouldBe("/healthz"); return new HttpResponseMessage(HttpStatusCode.OK); }); var loop = new PeerHttpProbeLoop(coordinator, tracker, factory, NullLogger.Instance); await loop.TickAsync(CancellationToken.None); tracker.Get("B").HttpHealthy.ShouldBeTrue(); } [Fact] public async Task Tick_marks_peer_unhealthy_when_healthz_throws() { var coordinator = await SeedAndInitializeAsync("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var tracker = new PeerReachabilityTracker(); var factory = new StubHttpClientFactory(_ => throw new HttpRequestException("no route to host")); var loop = new PeerHttpProbeLoop(coordinator, tracker, factory, NullLogger.Instance); await loop.TickAsync(CancellationToken.None); tracker.Get("B").HttpHealthy.ShouldBeFalse(); } [Fact] public async Task Tick_preserves_UaHealthy_bit_when_flipping_HttpHealthy() { var coordinator = await SeedAndInitializeAsync("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var tracker = new PeerReachabilityTracker(); tracker.Update("B", new PeerReachability(HttpHealthy: false, UaHealthy: true)); var factory = new StubHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.OK)); var loop = new PeerHttpProbeLoop(coordinator, tracker, factory, NullLogger.Instance); await loop.TickAsync(CancellationToken.None); var current = tracker.Get("B"); current.HttpHealthy.ShouldBeTrue(); current.UaHealthy.ShouldBeTrue("UA bit must not be clobbered by the HTTP probe"); } [Fact] public async Task Tick_marks_peer_unhealthy_on_non_2xx_response() { var coordinator = await SeedAndInitializeAsync("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var tracker = new PeerReachabilityTracker(); var factory = new StubHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); var loop = new PeerHttpProbeLoop(coordinator, tracker, factory, NullLogger.Instance); await loop.TickAsync(CancellationToken.None); tracker.Get("B").HttpHealthy.ShouldBeFalse(); } [Fact] public async Task Tick_does_not_mutate_factory_vended_client_Timeout() { // Server-012: timeouts belong on the named-client registration or a per-request CTS, // NOT on a factory-vended HttpClient (which IHttpClientFactory may pool/recycle). // Mutating client.Timeout per tick is at minimum a bad smell and races with // IHttpClientFactory's lifecycle expectations. var coordinator = await SeedAndInitializeAsync("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var tracker = new PeerReachabilityTracker(); var factoryInitialTimeout = TimeSpan.FromMinutes(2); var factory = new RecordingHttpClientFactory( _ => new HttpResponseMessage(HttpStatusCode.OK), factoryInitialTimeout); var loop = new PeerHttpProbeLoop(coordinator, tracker, factory, NullLogger.Instance, options: new PeerProbeOptions { HttpProbeTimeout = TimeSpan.FromSeconds(3) }); await loop.TickAsync(CancellationToken.None); factory.LastCreatedClient.ShouldNotBeNull(); factory.LastCreatedClient.Timeout.ShouldBe(factoryInitialTimeout, "the probe loop must not mutate the factory-vended HttpClient's Timeout — " + "per-call timeout should be enforced via a CancellationToken or via " + "AddHttpClient.ConfigureHttpClient on the named registration."); } // ---- fixture helpers --------------------------------------------------- private async Task SeedAndInitializeAsync(string selfNodeId, params (string id, RedundancyRole role, string appUri)[] nodes) { _db.ServerClusters.Add(new ServerCluster { ClusterId = "c1", Name = "Warsaw", Enterprise = "zb", Site = "warsaw", RedundancyMode = nodes.Length == 1 ? RedundancyMode.None : RedundancyMode.Warm, CreatedBy = "test", }); foreach (var (id, role, appUri) in nodes) { _db.ClusterNodes.Add(new ClusterNode { NodeId = id, ClusterId = "c1", RedundancyRole = role, Host = id.ToLowerInvariant(), ApplicationUri = appUri, CreatedBy = "test", }); } await _db.SaveChangesAsync(); var coordinator = new RedundancyCoordinator(_dbFactory, NullLogger.Instance, selfNodeId, "c1"); await coordinator.InitializeAsync(CancellationToken.None); return coordinator; } private sealed class DbContextFactory(DbContextOptions options) : IDbContextFactory { public OtOpcUaConfigDbContext CreateDbContext() => new(options); } private sealed class StubHttpClientFactory(Func respond) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(new StubHandler(respond), disposeHandler: true) { Timeout = TimeSpan.FromSeconds(1) }; private sealed class StubHandler(Func respond) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(respond(request)); } } /// /// Server-012 — captures the most-recently-vended so the /// test can assert the probe loop didn't mutate its . /// private sealed class RecordingHttpClientFactory( Func respond, TimeSpan initialTimeout) : IHttpClientFactory { public HttpClient? LastCreatedClient { get; private set; } public HttpClient CreateClient(string name) { var client = new HttpClient(new RecordingHandler(respond), disposeHandler: true) { Timeout = initialTimeout, }; LastCreatedClient = client; return client; } private sealed class RecordingHandler(Func respond) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(respond(request)); } } }