Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorHistoryUpdateRoleViewTests.cs
T
Joseph Doherty b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
test: migrate AdminUI/ControlPlane/Runtime/Host tests to xunit.v3 (#756)
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via
VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask,
SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass
TestContext.Current.CancellationToken. Test counts unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 08:57:33 -05:00

134 lines
6.1 KiB
C#

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;
/// <summary>
/// Gitea #674 review — the OPC UA HistoryUpdate verdict (<see cref="IHistoryUpdateRoleView"/>) 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.
/// </summary>
/// <remarks>
/// Assertions are on what was PUBLISHED, in order, so a missing publish cannot pass for a closed seed.
/// </remarks>
public sealed class DriverHostActorHistoryUpdateRoleViewTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-history-update-test");
private static readonly TimeSpan Timeout = PresenceBudget;
/// <summary>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.</summary>
[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, cancellationToken: TestContext.Current.CancellationToken);
host.Tell(Snapshot(RedundancyRole.Primary));
AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout, cancellationToken: TestContext.Current.CancellationToken);
}
/// <summary>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.</summary>
[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, cancellationToken: TestContext.Current.CancellationToken);
host.Tell(new RedundancyStateChanged([], CorrelationId.NewId()));
AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout, cancellationToken: TestContext.Current.CancellationToken);
}
/// <summary>A Secondary stays closed after its snapshot; becoming Primary opens it.</summary>
[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, cancellationToken: TestContext.Current.CancellationToken);
host.Tell(Snapshot(RedundancyRole.Secondary));
AwaitAssert(() => view.Published.Count.ShouldBeGreaterThanOrEqualTo(2), duration: Timeout, cancellationToken: TestContext.Current.CancellationToken);
view.Published.ShouldAllBe(v => !v);
host.Tell(Snapshot(RedundancyRole.Primary));
AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout, cancellationToken: TestContext.Current.CancellationToken);
}
/// <summary>The policy itself: no snapshot ⇒ closed whatever the role/count; with one ⇒ the data-plane verdict.</summary>
[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);
/// <summary>The production view is seeded CLOSED (unlike the data-plane verdict's allow seed).</summary>
[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<int> 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());
/// <summary>Remembers every published verdict, in order.</summary>
private sealed class RecordingHistoryUpdateRoleView : IHistoryUpdateRoleView
{
private readonly List<bool> _published = [];
private readonly Lock _gate = new();
public IReadOnlyList<bool> 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);
}
}
}