using Shouldly; using Xunit; 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 ClusterTopologyLoaderTests { private static ServerCluster Cluster(RedundancyMode mode = RedundancyMode.Warm) => new() { ClusterId = "c1", Name = "Warsaw-West", Enterprise = "zb", Site = "warsaw-west", RedundancyMode = mode, CreatedBy = "test", }; private static ClusterNode Node(string id, RedundancyRole role, string host, int port = 4840, string? appUri = null) => new() { NodeId = id, ClusterId = "c1", RedundancyRole = role, Host = host, OpcUaPort = port, ApplicationUri = appUri ?? $"urn:{host}:OtOpcUa", CreatedBy = "test", }; [Fact] public void SingleNode_Standalone_Loads() { var cluster = Cluster(RedundancyMode.None); var nodes = new[] { Node("A", RedundancyRole.Standalone, "hostA") }; var topology = ClusterTopologyLoader.Load("A", cluster, nodes); topology.SelfNodeId.ShouldBe("A"); topology.SelfRole.ShouldBe(RedundancyRole.Standalone); topology.Peers.ShouldBeEmpty(); topology.SelfApplicationUri.ShouldBe("urn:hostA:OtOpcUa"); } [Fact] public void TwoNode_Cluster_LoadsSelfAndPeer() { var cluster = Cluster(); var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA"), Node("B", RedundancyRole.Secondary, "hostB"), }; var topology = ClusterTopologyLoader.Load("A", cluster, nodes); topology.SelfNodeId.ShouldBe("A"); topology.SelfRole.ShouldBe(RedundancyRole.Primary); topology.Peers.Count.ShouldBe(1); topology.Peers[0].NodeId.ShouldBe("B"); topology.Peers[0].Role.ShouldBe(RedundancyRole.Secondary); } [Fact] public void ServerUriArray_Puts_Self_First_Peers_SortedLexicographically() { var cluster = Cluster(); var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA", appUri: "urn:A"), Node("B", RedundancyRole.Secondary, "hostB", appUri: "urn:B"), }; var topology = ClusterTopologyLoader.Load("A", cluster, nodes); topology.ServerUriArray().ShouldBe(["urn:A", "urn:B"]); } [Fact] public void EmptyNodes_Throws() { Should.Throw( () => ClusterTopologyLoader.Load("A", Cluster(), [])); } [Fact] public void SelfNotInCluster_Throws() { var nodes = new[] { Node("B", RedundancyRole.Primary, "hostB") }; Should.Throw( () => ClusterTopologyLoader.Load("A-missing", Cluster(), nodes)); } [Fact] public void ThreeNodeCluster_Rejected_Per_Decision83() { var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA"), Node("B", RedundancyRole.Secondary, "hostB"), Node("C", RedundancyRole.Secondary, "hostC"), }; var ex = Should.Throw( () => ClusterTopologyLoader.Load("A", Cluster(), nodes)); ex.Message.ShouldContain("decision #83"); } [Fact] public void DuplicateApplicationUri_Rejected() { var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA", appUri: "urn:shared"), Node("B", RedundancyRole.Secondary, "hostB", appUri: "urn:shared"), }; var ex = Should.Throw( () => ClusterTopologyLoader.Load("A", Cluster(), nodes)); ex.Message.ShouldContain("ApplicationUri"); } [Fact] public void TwoPrimaries_InWarmMode_Rejected() { var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA"), Node("B", RedundancyRole.Primary, "hostB"), }; var ex = Should.Throw( () => ClusterTopologyLoader.Load("A", Cluster(RedundancyMode.Warm), nodes)); ex.Message.ShouldContain("2 Primary"); } [Fact] public void CrossCluster_Node_Rejected() { var foreign = Node("B", RedundancyRole.Secondary, "hostB"); foreign.ClusterId = "c-other"; var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA"), foreign }; Should.Throw( () => ClusterTopologyLoader.Load("A", Cluster(), nodes)); } [Fact] public void None_Mode_Allows_Any_Role_Mix() { // Standalone clusters don't enforce Primary-count; operator can pick anything. var cluster = Cluster(RedundancyMode.None); var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA") }; var topology = ClusterTopologyLoader.Load("A", cluster, nodes); topology.Mode.ShouldBe(RedundancyMode.None); } }