using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Review R1 m2 (Gitea #597) — the Primary data-plane verdict published to /// (read by the native-ack gate and the Sparkplug primary-host claim) mirrors the host's own live gate from the /// moment the host starts, not only from the first redundancy snapshot. /// /// /// RedundancyRoleView is seeded with the single-node verdict (allow). Before this fix nothing published to /// it until a snapshot arrived, so on a pair both nodes accepted a native ack through the whole boot window while /// DriverHostActor — which evaluates the driver member count live — denied the upstream route on the /// non-Primary. Assertions are on what was PUBLISHED: the seed already reads true, so a current-value /// assertion of the single-node case would pass with no publish at all. /// public sealed class DriverHostActorPrimaryRoleSourceTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-primary-source-test"); private static readonly TimeSpan Timeout = PresenceBudget; /// On a pair, an unknown role publishes DENY at start — before any snapshot. The later Primary snapshot /// is the positive control: the source is wired, and the deny was the verdict, not a missing publish. [Fact] public void A_paired_host_publishes_deny_before_the_first_snapshot() { var source = new RecordingPrimaryRoleSource(); var host = SpawnHost(source, () => 2); AwaitAssert(() => source.Published.ShouldBe([false]), duration: Timeout); host.Tell(Snapshot(RedundancyRole.Primary)); AwaitAssert(() => source.Published.ShouldBe([false, true]), duration: Timeout); } /// A single-driver node keeps the allow posture — it is published, not merely seeded. [Fact] public void A_single_node_host_publishes_allow_at_start() { var source = new RecordingPrimaryRoleSource(); SpawnHost(source, () => 1); AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout); } /// A driver peer coming Up while the role is still unknown flips the published verdict to deny, with no /// snapshot involved — the membership re-publish path. [Fact] public void A_peer_joining_before_any_snapshot_flips_the_published_verdict_to_deny() { var source = new RecordingPrimaryRoleSource(); var members = 1; var host = SpawnHost(source, () => Volatile.Read(ref members)); AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout); Volatile.Write(ref members, 2); host.Tell(DriverHostActor.RepublishPrimaryVerdict.Instance); AwaitAssert(() => source.Published.ShouldBe([true, false]), duration: Timeout); } /// /// Review R6 MINOR-3 — the same flip driven by a real cluster MEMBER EVENT rather than a direct /// tell, so it fails if the host's /// Cluster.Subscribe(… IMemberEvent) or its IMemberEvent handler is deleted. The event is published /// on the ActorSystem EventStream, which is exactly how the cluster's domain-event publisher delivers it to a /// Cluster.Subscribe subscriber — and to nothing else. Re-published inside the wait because the /// subscription is registered asynchronously by the publisher actor after PreStart. /// [Fact] public void A_cluster_member_event_flips_the_published_verdict_without_a_direct_republish() { var source = new RecordingPrimaryRoleSource(); var members = 1; SpawnHost(source, () => Volatile.Read(ref members)); AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout); Volatile.Write(ref members, 2); var memberUp = new Akka.Cluster.ClusterEvent.MemberUp(Akka.Cluster.Cluster.Get(Sys).SelfMember); AwaitAssert(() => { Sys.EventStream.Publish(memberUp); source.Published.ShouldBe([true, false]); }, duration: Timeout, interval: TimeSpan.FromMilliseconds(100)); } private IActorRef SpawnHost(IDriverPrimaryRoleSource source, Func driverMemberCount) => Sys.ActorOf(DriverHostActor.Props( NewInMemoryDbFactory(), TestNode, ackRouter: null, driverMemberCountProvider: driverMemberCount, primaryRoleSource: source)); private static RedundancyStateChanged Snapshot(RedundancyRole role) => new( [ new NodeRedundancyState(TestNode, role, IsClusterLeader: role == RedundancyRole.Primary, IsDriverPrimary: role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), ], CorrelationId.NewId()); /// Remembers every published verdict, so tests assert on the actor's action, not on the seed. private sealed class RecordingPrimaryRoleSource : IDriverPrimaryRoleSource { private readonly List _published = []; private readonly Lock _gate = new(); /// Every value published so far, in order. public IReadOnlyList Published { get { lock (_gate) return _published.ToArray(); } } public bool ShouldActAsPrimary { get { lock (_gate) return _published.Count == 0 || _published[^1]; } } public void PublishPrimaryRole(bool shouldActAsPrimary) { lock (_gate) _published.Add(shouldActAsPrimary); } } }