using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Cluster; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// /// Unit tests for — the pure decision core of the /// simultaneous-cold-start split-brain guard. The runtime probe + JoinSeedNodes wiring lives in /// ClusterBootstrapCoordinator and is covered by the live gate. /// public sealed class ClusterBootstrapGuardTests { private const string A = "akka.tcp://otopcua@site-a-1:4053"; private const string B = "akka.tcp://otopcua@site-a-2:4053"; [Fact] public void Lower_address_node_is_the_founder_and_needs_no_probe() { // site-a-1 < site-a-2, so on site-a-1 the guard makes it the founder. var role = ClusterBootstrapGuard.Analyze(new[] { A, B }, "site-a-1", 4053); role.Applies.ShouldBeTrue(); role.IsFounder.ShouldBeTrue(); role.SelfFirstOrder.ShouldBe(new[] { A, B }); } [Fact] public void Higher_address_node_is_not_the_founder_and_targets_the_partner_for_probing() { // On site-a-2, the partner is site-a-1 (the lower/founder). var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053); role.Applies.ShouldBeTrue(); role.IsFounder.ShouldBeFalse(); role.PartnerHost.ShouldBe("site-a-1"); role.PartnerPort.ShouldBe(4053); } [Fact] public void Tie_break_is_symmetric_regardless_of_seed_order() { // Both nodes must agree on who founds no matter how each lists its seeds. var onLower = ClusterBootstrapGuard.Analyze(new[] { A, B }, "site-a-1", 4053); var onHigher = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053); onLower.IsFounder.ShouldBeTrue(); onHigher.IsFounder.ShouldBeFalse(); // Exactly one founder. (onLower.IsFounder ^ onHigher.IsFounder).ShouldBeTrue(); } [Fact] public void Higher_node_joins_the_founder_when_partner_is_reachable() { var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053); // Reachable ⇒ peer-first ⇒ JoinSeedNodeProcess ⇒ joins the founder, never self-forms. ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: true) .ShouldBe(new[] { A, B }); // partner (site-a-1) first } [Fact] public void Higher_node_forms_alone_when_partner_is_unreachable() { var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053); // Unreachable after the window ⇒ partner is dead ⇒ self-first ⇒ cold-start-alone preserved. ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: false) .ShouldBe(new[] { B, A }); // self (site-a-2) first } [Fact] public void Guard_does_not_apply_to_a_single_seed_node() { // A driver-only site node seeded solely by central-1 is not a pair seed — guard is inert. var role = ClusterBootstrapGuard.Analyze(new[] { "akka.tcp://otopcua@central-1:4053" }, "site-x", 4053); role.Applies.ShouldBeFalse(); } [Fact] public void Guard_does_not_apply_when_self_is_absent_from_the_two_seeds() { var role = ClusterBootstrapGuard.Analyze(new[] { A, B }, "central-1", 4053); role.Applies.ShouldBeFalse(); } [Fact] public void Guard_does_not_apply_to_three_or_more_seeds() { var role = ClusterBootstrapGuard.Analyze( new[] { A, B, "akka.tcp://otopcua@site-a-3:4053" }, "site-a-1", 4053); role.Applies.ShouldBeFalse(); } [Fact] public void Guard_does_not_apply_when_a_seed_is_malformed() { var role = ClusterBootstrapGuard.Analyze(new[] { A, "not-a-seed" }, "site-a-1", 4053); role.Applies.ShouldBeFalse(); } [Theory] [InlineData("akka.tcp://otopcua@site-a-1:4053", "site-a-1", 4053)] [InlineData("akka.tcp://otopcua@10.0.0.5:4053/", "10.0.0.5", 4053)] [InlineData(" akka.tcp://otopcua@host.lan:1234 ", "host.lan", 1234)] public void TryParse_extracts_host_and_port(string seed, string expectedHost, int expectedPort) { ClusterBootstrapGuard.TryParse(seed, out var host, out var port).ShouldBeTrue(); host.ShouldBe(expectedHost); port.ShouldBe(expectedPort); } [Theory] [InlineData("")] [InlineData("garbage")] [InlineData("akka.tcp://otopcua@host")] // no port public void TryParse_rejects_malformed_seeds(string seed) { ClusterBootstrapGuard.TryParse(seed, out _, out _).ShouldBeFalse(); } [Fact] public void Port_participates_in_the_tie_break_when_hosts_are_equal() { // Shared-host loopback pair (test rig): same host, different ports — port breaks the tie. const string low = "akka.tcp://otopcua@127.0.0.1:4053"; const string high = "akka.tcp://otopcua@127.0.0.1:4054"; ClusterBootstrapGuard.Analyze(new[] { low, high }, "127.0.0.1", 4053).IsFounder.ShouldBeTrue(); ClusterBootstrapGuard.Analyze(new[] { high, low }, "127.0.0.1", 4054).IsFounder.ShouldBeFalse(); } }