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.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Gitea #674 review — the OPC UA HistoryUpdate verdict () is the Primary /// data-plane verdict but CLOSED until this node's first redundancy snapshot, so both nodes of a pair cannot accept a /// HistoryUpdate during the pre-join boot window (where the data-plane verdict allows: member count 0/1). A single node /// still opens — its own redundancy singleton names it Primary in its first snapshot. /// /// /// Assertions are on what was PUBLISHED, in order, so a missing publish cannot pass for a closed seed. /// public sealed class DriverHostActorHistoryUpdateRoleViewTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-history-update-test"); private static readonly TimeSpan Timeout = PresenceBudget; /// The boot window: a single-driver node (data-plane verdict ALLOW) publishes CLOSED for HistoryUpdate until /// a snapshot arrives; its first snapshot naming it Primary opens it. [Fact] public void A_single_node_is_closed_until_its_first_snapshot_then_opens() { var view = new RecordingHistoryUpdateRoleView(); var host = SpawnHost(view, () => 1); AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout); host.Tell(Snapshot(RedundancyRole.Primary)); AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout); } /// Once a snapshot has arrived, an unknown role on a single-driver cluster (a snapshot that omits this /// node) follows the data-plane rule and opens — a real verdict exists, it just does not name us. [Fact] public void After_a_snapshot_an_unknown_role_follows_the_data_plane_rule() { var view = new RecordingHistoryUpdateRoleView(); var host = SpawnHost(view, () => 1); AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout); host.Tell(new RedundancyStateChanged([], CorrelationId.NewId())); AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout); } /// A Secondary stays closed after its snapshot; becoming Primary opens it. [Fact] public void A_secondary_stays_closed_and_a_promotion_opens() { var view = new RecordingHistoryUpdateRoleView(); var host = SpawnHost(view, () => 2); AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout); host.Tell(Snapshot(RedundancyRole.Secondary)); AwaitAssert(() => view.Published.Count.ShouldBeGreaterThanOrEqualTo(2), duration: Timeout); view.Published.ShouldAllBe(v => !v); host.Tell(Snapshot(RedundancyRole.Primary)); AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout); } /// The policy itself: no snapshot ⇒ closed whatever the role/count; with one ⇒ the data-plane verdict. [Theory] [InlineData(false, null, 0, false)] [InlineData(false, null, 1, false)] [InlineData(false, RedundancyRole.Primary, 1, false)] [InlineData(true, null, 1, true)] [InlineData(true, null, 2, false)] [InlineData(true, RedundancyRole.Primary, 2, true)] [InlineData(true, RedundancyRole.Secondary, 2, false)] [InlineData(true, RedundancyRole.Detached, 1, false)] public void Policy_is_the_data_plane_verdict_gated_on_a_snapshot( bool snapshot, RedundancyRole? role, int members, bool expected) => PrimaryGatePolicy.ShouldServeHistoryUpdate(snapshot, role, members).ShouldBe(expected); /// The production view is seeded CLOSED (unlike the data-plane verdict's allow seed). [Fact] public void The_role_view_is_seeded_closed() { var view = new RedundancyRoleView(); view.ShouldServeHistoryUpdate.ShouldBeFalse(); view.ShouldActAsPrimary.ShouldBeTrue("control: the data-plane seed is the single-node allow"); view.PublishHistoryUpdateRole(true); view.ShouldServeHistoryUpdate.ShouldBeTrue(); } private IActorRef SpawnHost(IHistoryUpdateRoleView view, Func driverMemberCount) => Sys.ActorOf(DriverHostActor.Props( NewInMemoryDbFactory(), TestNode, ackRouter: null, driverMemberCountProvider: driverMemberCount, historyUpdateRoleView: view)); 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, in order. private sealed class RecordingHistoryUpdateRoleView : IHistoryUpdateRoleView { private readonly List _published = []; private readonly Lock _gate = new(); public IReadOnlyList Published { get { lock (_gate) return _published.ToArray(); } } public bool ShouldServeHistoryUpdate { get { lock (_gate) return _published.Count > 0 && _published[^1]; } } public void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate) { lock (_gate) _published.Add(shouldServeHistoryUpdate); } } }