using Microsoft.Extensions.Configuration; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Types; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; /// /// Review R6 MINOR-4 — how the PASSIVE half of a LocalDb pair (ApiKey + SyncListenPort, no /// PeerAddress) learns which node holds its replicated alarm queue: its per-cluster mesh partner. /// Before this it had no peer at all, so as Secondary it kept draining beside its Primary partner and the /// historian received every alarm event twice. /// public sealed class AlarmQueuePeerPredicateTests { private static readonly NodeId Self = NodeId.Parse("site-a-2:4053"); private static readonly NodeId Partner = NodeId.Parse("site-a-1:4053"); private static readonly NodeId OtherPair = NodeId.Parse("site-b-1:4053"); /// The passive half (key + listener) with a mesh role: its mesh partner is its queue peer, itself and /// a node from another mesh are not. [Fact] public void The_passive_half_identifies_its_mesh_partner() { var predicate = ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate( Config(apiKey: "k", syncListenPort: "4057"), new MeshRoleInfo("cluster-SITE-A", Self, Partner), replicationPeerHost: null); predicate.ShouldNotBeNull(); predicate(Partner).ShouldBeTrue(); predicate(Self).ShouldBeFalse(); predicate(OtherPair).ShouldBeFalse(); } /// The dialling half is matched by its PeerAddress host instead — no predicate. [Fact] public void The_dialling_half_needs_no_predicate() => ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate( Config(apiKey: "k", peerAddress: "http://site-a-2:4057"), new MeshRoleInfo("cluster-SITE-A", Self, Partner), replicationPeerHost: "site-a-2").ShouldBeNull(); /// A node sharing its queue with nobody must never stand down, whatever its mesh says. [Theory] [InlineData(null, "4057")] // listener without the key — fail-closed, shares nothing [InlineData("k", null)] // key without a path — shares nothing [InlineData(null, null)] // unreplicated public void A_node_that_shares_no_rows_gets_no_predicate(string? apiKey, string? syncListenPort) => ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate( Config(apiKey: apiKey, syncListenPort: syncListenPort), new MeshRoleInfo("cluster-SITE-A", Self, Partner), replicationPeerHost: null).ShouldBeNull(); /// A legacy node with no cluster-scoped role cannot prove which node is its partner; standing down for /// the wrong Primary would strand its rows, so it gets none (and keeps draining). [Fact] public void A_node_without_a_mesh_role_gets_no_predicate() => ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate( Config(apiKey: "k", syncListenPort: "4057"), new MeshRoleInfo(null, Self, Partner), replicationPeerHost: null) .ShouldBeNull(); private static IConfiguration Config(string? apiKey = null, string? peerAddress = null, string? syncListenPort = null) => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["LocalDb:Replication:ApiKey"] = apiKey, ["LocalDb:Replication:PeerAddress"] = peerAddress, ["LocalDb:SyncListenPort"] = syncListenPort, }).Build(); private sealed class MeshRoleInfo(string? clusterRole, NodeId self, NodeId partner) : IClusterRoleInfo { public NodeId LocalNode => self; public IReadOnlySet LocalRoles { get; } = new HashSet(["driver"]); public string? ClusterRole => clusterRole; public string? ClusterId => clusterRole?["cluster-".Length..]; public bool HasRole(string role) => LocalRoles.Contains(role) || role == clusterRole; public IReadOnlyList MembersWithRole(string role) => role == clusterRole ? [self, partner] : []; public NodeId? RoleLeader(string role) => null; public event EventHandler? RoleLeaderChanged { add { _ = value; } remove { _ = value; } } } }