using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// /// Verifies publishes a /// snapshot in response to cluster events, and that the 250ms debounce coalesces bursts. /// The actor accepts an Action<object> broadcast override so tests can use a /// TestProbe sink instead of bootstrapping DistributedPubSub (which is flaky single-node). /// public sealed class RedundancyStateActorTests : ControlPlaneActorTestBase { /// Verifies that a self-join event triggers RedundancyStateChanged through the broadcast override. [Fact] public void Self_join_triggers_RedundancyStateChanged_via_broadcast_override() { var probe = CreateTestProbe("redundancy-listener"); Sys.ActorOf(RedundancyStateActor.Props(broadcast: msg => probe.Ref.Tell(msg)), "redundancy-actor"); var msg = probe.ExpectMsg(TimeSpan.FromSeconds(3)); msg.Nodes.ShouldNotBeNull(); msg.CorrelationId.Value.ShouldNotBe(Guid.Empty); } /// Verifies that multiple back-to-back events debounce to a single RedundancyStateChanged publication. [Fact] public void Multiple_back_to_back_events_debounce_to_single_publish() { var probe = CreateTestProbe("dedup-listener"); Sys.ActorOf(RedundancyStateActor.Props(broadcast: msg => probe.Ref.Tell(msg)), "redundancy-debounce"); // First publish should arrive within the debounce window. probe.ExpectMsg(TimeSpan.FromSeconds(3)); // After debounce settles, no more events are fired by a quiescent cluster. probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// /// Verifies the periodic heartbeat re-publishes the CURRENT snapshot even when no new cluster /// events arrive — so a late subscriber that missed the change-driven publish converges within /// the heartbeat interval. The first message is the self-join publish; the second proves the /// recurring heartbeat fires with no intervening cluster event. /// [Fact] public void Heartbeat_republishes_current_snapshot_without_cluster_events() { var probe = CreateTestProbe("heartbeat-listener"); Sys.ActorOf( RedundancyStateActor.Props( broadcast: msg => probe.Ref.Tell(msg), heartbeatInterval: TimeSpan.FromMilliseconds(300)), "redundancy-heartbeat"); // First publish: the change-driven self-join snapshot. probe.ExpectMsg(TimeSpan.FromSeconds(2)); // Second publish: the periodic heartbeat re-publish, with NO new cluster event sent. probe.ExpectMsg(TimeSpan.FromSeconds(2)); } /// /// Regression guard: the snapshot node id MUST be the canonical host:port form (matching /// ClusterRoleInfo.LocalNode/ToNodeId), or every consumer's /// n.NodeId == _localNode.Value match fails and no node ever learns its role. /// [Fact] public void ToNodeId_uses_canonical_host_and_port() { var nodeId = RedundancyStateActor.ToNodeId( new Address("akka.tcp", "otopcua", "central-2", 4053)); nodeId.Value.ShouldBe("central-2:4053"); } /// /// Documents the host-less/port-less fallback (:0). Such members are skipped by /// BuildSnapshot's guard, but the helper must still format deterministically. /// [Fact] public void ToNodeId_handles_missing_port() { var nodeId = RedundancyStateActor.ToNodeId(new Address("akka.tcp", "otopcua")); nodeId.Value.ShouldBe(":0"); } }