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.Redundancy; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; [Trait("Category", "Unit")] public sealed class RedundancyStatePublisherTests : IDisposable { private readonly OtOpcUaConfigDbContext _db; private readonly IDbContextFactory _dbFactory; public RedundancyStatePublisherTests() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"redundancy-publisher-{Guid.NewGuid():N}") .Options; _db = new OtOpcUaConfigDbContext(options); _dbFactory = new DbContextFactory(options); } public void Dispose() => _db.Dispose(); private sealed class DbContextFactory(DbContextOptions options) : IDbContextFactory { public OtOpcUaConfigDbContext CreateDbContext() => new(options); } private async Task SeedAndInitialize(string selfNodeId, params (string id, RedundancyRole role, string appUri)[] nodes) { var cluster = new ServerCluster { ClusterId = "c1", Name = "Warsaw-West", Enterprise = "zb", Site = "warsaw-west", RedundancyMode = nodes.Length == 1 ? RedundancyMode.None : RedundancyMode.Warm, CreatedBy = "test", }; _db.ServerClusters.Add(cluster); 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; } [Fact] public async Task BeforeInit_Publishes_NoData() { // Coordinator not initialized — current topology is null. var coordinator = new RedundancyCoordinator(_dbFactory, NullLogger.Instance, "A", "c1"); var publisher = new RedundancyStatePublisher( coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), new PeerReachabilityTracker()); var snap = publisher.ComputeAndPublish(); snap.Band.ShouldBe(ServiceLevelBand.NoData); snap.Value.ShouldBe((byte)1); await Task.Yield(); } [Fact] public async Task AuthoritativePrimary_WhenHealthyAndPeerReachable() { var coordinator = await SeedAndInitialize("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var peers = new PeerReachabilityTracker(); peers.Update("B", PeerReachability.FullyHealthy); var publisher = new RedundancyStatePublisher( coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers); var snap = publisher.ComputeAndPublish(); snap.Value.ShouldBe((byte)255); snap.Band.ShouldBe(ServiceLevelBand.AuthoritativePrimary); } [Fact] public async Task IsolatedPrimary_WhenPeerUnreachable_RetainsAuthority() { var coordinator = await SeedAndInitialize("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var peers = new PeerReachabilityTracker(); peers.Update("B", PeerReachability.Unknown); var publisher = new RedundancyStatePublisher( coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers); var snap = publisher.ComputeAndPublish(); snap.Value.ShouldBe((byte)230); } [Fact] public async Task MidApply_WhenLeaseOpen_Dominates() { var coordinator = await SeedAndInitialize("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var leases = new ApplyLeaseRegistry(); var peers = new PeerReachabilityTracker(); peers.Update("B", PeerReachability.FullyHealthy); await using var lease = leases.BeginApplyLease(1, Guid.NewGuid()); var publisher = new RedundancyStatePublisher( coordinator, leases, new RecoveryStateManager(), peers); var snap = publisher.ComputeAndPublish(); snap.Value.ShouldBe((byte)200); } [Fact] public async Task SelfUnhealthy_Returns_NoData() { var coordinator = await SeedAndInitialize("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var peers = new PeerReachabilityTracker(); peers.Update("B", PeerReachability.FullyHealthy); var publisher = new RedundancyStatePublisher( coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers, selfHealthy: () => false); var snap = publisher.ComputeAndPublish(); snap.Value.ShouldBe((byte)1); } [Fact] public async Task OnStateChanged_FiresOnly_OnValueChange() { var coordinator = await SeedAndInitialize("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var peers = new PeerReachabilityTracker(); peers.Update("B", PeerReachability.FullyHealthy); var publisher = new RedundancyStatePublisher( coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers); var emitCount = 0; byte? lastEmitted = null; publisher.OnStateChanged += snap => { emitCount++; lastEmitted = snap.Value; }; publisher.ComputeAndPublish(); // first tick — emits 255 since _lastByte was seeded at 255; no change peers.Update("B", PeerReachability.Unknown); publisher.ComputeAndPublish(); // 255 → 230 transition — emits publisher.ComputeAndPublish(); // still 230 — no emit emitCount.ShouldBe(1); lastEmitted.ShouldBe((byte)230); } [Fact] public async Task OnServerUriArrayChanged_FiresOnce_PerTopology() { var coordinator = await SeedAndInitialize("A", ("A", RedundancyRole.Primary, "urn:A"), ("B", RedundancyRole.Secondary, "urn:B")); var peers = new PeerReachabilityTracker(); peers.Update("B", PeerReachability.FullyHealthy); var publisher = new RedundancyStatePublisher( coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers); var emits = new List>(); publisher.OnServerUriArrayChanged += arr => emits.Add(arr); publisher.ComputeAndPublish(); publisher.ComputeAndPublish(); publisher.ComputeAndPublish(); emits.Count.ShouldBe(1, "ServerUriArray event is edge-triggered on topology content change"); emits[0].ShouldBe(["urn:A", "urn:B"]); } [Fact] public async Task Standalone_Cluster_IsAuthoritative_When_Healthy() { var coordinator = await SeedAndInitialize("A", ("A", RedundancyRole.Standalone, "urn:A")); var publisher = new RedundancyStatePublisher( coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), new PeerReachabilityTracker()); var snap = publisher.ComputeAndPublish(); snap.Value.ShouldBe((byte)255); } }