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 /// with an injected endpoint-probe delegate so no real OPC UA server is needed. /// [Trait("Category", "Unit")] public sealed class PeerUaProbeLoopTests : IDisposable { private readonly OtOpcUaConfigDbContext _db; private readonly IDbContextFactory _dbFactory; public PeerUaProbeLoopTests() { var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase($"peer-ua-{Guid.NewGuid():N}") .Options; _db = new OtOpcUaConfigDbContext(opts); _dbFactory = new DbContextFactory(opts); } public void Dispose() => _db.Dispose(); [Fact] public async Task Tick_short_circuits_when_HttpHealthy_is_false() { 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 probeCallCount = 0; var loop = new PeerUaProbeLoop(coordinator, tracker, NullLogger.Instance, options: null, endpointProbe: (_, _, _) => { probeCallCount++; return Task.FromResult(true); }); await loop.TickAsync(CancellationToken.None); probeCallCount.ShouldBe(0, "UA probe must not run when HTTP reports the peer unhealthy"); var current = tracker.Get("B"); current.HttpHealthy.ShouldBeFalse(); current.UaHealthy.ShouldBeFalse("stale UaHealthy=true must be cleared when HTTP says dead"); } [Fact] public async Task Tick_marks_UaHealthy_true_when_probe_succeeds() { 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: true, UaHealthy: false)); string? calledEndpoint = null; var loop = new PeerUaProbeLoop(coordinator, tracker, NullLogger.Instance, options: null, endpointProbe: (endpoint, _, _) => { calledEndpoint = endpoint; return Task.FromResult(true); }); await loop.TickAsync(CancellationToken.None); calledEndpoint.ShouldNotBeNull(); calledEndpoint!.ShouldStartWith("opc.tcp://b:"); tracker.Get("B").UaHealthy.ShouldBeTrue(); } [Fact] public async Task Tick_marks_UaHealthy_false_when_probe_fails() { 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: true, UaHealthy: true)); var loop = new PeerUaProbeLoop(coordinator, tracker, NullLogger.Instance, options: null, endpointProbe: (_, _, _) => Task.FromResult(false)); await loop.TickAsync(CancellationToken.None); tracker.Get("B").UaHealthy.ShouldBeFalse(); } [Fact] public async Task Tick_preserves_HttpHealthy_bit_across_UA_update() { 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: true, UaHealthy: false)); var loop = new PeerUaProbeLoop(coordinator, tracker, NullLogger.Instance, options: null, endpointProbe: (_, _, _) => Task.FromResult(true)); await loop.TickAsync(CancellationToken.None); var current = tracker.Get("B"); current.HttpHealthy.ShouldBeTrue("HTTP bit must not be clobbered by the UA probe"); current.UaHealthy.ShouldBeTrue(); } // ---- 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); } }