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