Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigPublishCoordinatorDbSourcedAcksTests.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

264 lines
12 KiB
C#

using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// Per-cluster mesh Phase 1: the coordinator's expected-ack set comes from enabled
/// <c>ClusterNode</c> rows, not from <c>Akka.Cluster.State.Members</c> filtered by role.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why every test here seeds ClusterNode rows and asserts a non-empty expected set.</b>
/// The harness's ActorSystem self-joins with role <c>admin</c> and no <c>driver</c> members,
/// so the OLD derivation returned an empty set for every one of these scenarios and sealed
/// immediately. Any test that merely observes "the deployment did not seal" would therefore
/// have passed against the old code too. Each test below distinguishes the two by requiring
/// the coordinator to WAIT for a specific node and then seal when exactly that node acks —
/// behaviour the membership rule cannot produce in this harness at all.
/// </para>
/// <para>
/// Verified by reverting <c>DiscoverDriverNodes</c> to the membership scan: the first three
/// go red. <see cref="No_enabled_rows_seals_empty"/> stays green under both derivations and
/// is <b>not</b> a test of the derivation — the harness has no driver members AND no enabled
/// rows, so both rules produce an empty set. It pins the seal-empty branch and its reworded
/// reason, nothing more.
/// </para>
/// </remarks>
public sealed class ConfigPublishCoordinatorDbSourcedAcksTests : ControlPlaneActorTestBase
{
private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('b', 64));
private static readonly TimeSpan NoTimeout = TimeSpan.FromMinutes(5);
/// <summary>
/// An enabled row is expected to ack; the deployment waits for it and seals when it arrives.
/// This is the DB-derived set doing something the membership set demonstrably cannot here.
/// </summary>
[Fact]
public void Enabled_ClusterNode_rows_are_the_expected_ack_set()
{
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDispatchingDeployment(dbFactory);
SeedNodes(dbFactory, ("site-a-1:4053", true, false));
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
// The seeded row must have produced a NodeDeploymentState row — proof the coordinator
// expected it, independent of whether it later sealed.
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.NodeDeploymentStates.Select(s => s.NodeId).ShouldBe(new[] { "site-a-1:4053" });
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
}, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
}
/// <summary>
/// A disabled row is NOT expected, so a deployment seals without it — for a node genuinely
/// decommissioned rather than temporarily down. <b>This is not the maintenance hatch</b>: the
/// topology gate rejects a deployment whose enabled-node count differs from the cluster's
/// <c>NodeCount</c>, so on any 2-node pair clearing <c>Enabled</c> never reaches this code.
/// See <see cref="MaintenanceMode_rows_are_not_expected_to_ack_while_staying_enabled"/>.
/// </summary>
[Fact]
public void Disabled_ClusterNode_rows_are_not_expected_to_ack()
{
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDispatchingDeployment(dbFactory);
SeedNodes(dbFactory, ("site-a-1:4053", true, false), ("site-a-2:4053", false, false));
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
// Only the enabled node is expected — the disabled one gets no NodeDeploymentState row.
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.NodeDeploymentStates.Select(s => s.NodeId).ShouldBe(new[] { "site-a-1:4053" });
}, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
// ...and the enabled node's ack alone seals it. If the disabled row were expected this would
// sit at AwaitingApplyAcks until the deadline.
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
}, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
}
/// <summary>
/// An ack from a node with no <c>ClusterNode</c> row is discarded — it is not in the expected
/// set, so it must not count toward the seal. Under the membership rule a running-but-
/// unregistered node WAS expected, and then threw FK 547 when its state row was written.
/// </summary>
[Fact]
public void Ack_from_a_node_absent_from_ClusterNode_is_discarded()
{
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDispatchingDeployment(dbFactory);
SeedNodes(dbFactory, ("site-a-1:4053", true, false));
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
// A node nobody registered acks. One expected node, one ack — if it were counted, the
// coordinator would seal here.
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("ghost-9:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
ExpectNoMsg(TimeSpan.FromMilliseconds(400), TestContext.Current.CancellationToken);
using (var db = dbFactory.CreateDbContext())
{
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}
// Positive control on the wait itself: the real node's ack DOES seal it, so the assertion
// above measured the discard rather than a coordinator that had simply stopped responding.
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
}, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
}
/// <summary>
/// No enabled rows at all seals empty. Same outcome as the old empty-membership branch, but
/// it now means "the fleet has no enabled nodes registered" — a configuration error that
/// cannot resolve itself — rather than "no driver node has joined yet".
/// </summary>
[Fact]
public void No_enabled_rows_seals_empty()
{
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDispatchingDeployment(dbFactory);
SeedNodes(dbFactory, ("site-a-1:4053", false, false), ("site-a-2:4053", false, false));
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
db.NodeDeploymentStates.ShouldBeEmpty();
}, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
}
/// <summary>
/// The maintenance hatch, and the reason it is a separate flag. The Phase 1 live gate set
/// <c>Enabled = 0</c> on one node of a 2-node pair and the deployment was rejected outright by
/// <c>DraftValidator.ValidateClusterTopology</c> (enabled-node count must equal
/// <c>NodeCount</c>) — so the intended escape hatch could not be used on any pair, which is
/// every cluster in the target topology. <c>MaintenanceMode</c> excludes a node from the
/// expected-ack set while leaving it enabled, so the topology gate still passes.
/// </summary>
[Fact]
public void MaintenanceMode_rows_are_not_expected_to_ack_while_staying_enabled()
{
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDispatchingDeployment(dbFactory);
SeedNodes(dbFactory, ("site-a-1:4053", true, false), ("site-a-2:4053", true, true));
// The topology gate counts Enabled, so both nodes still count — this is what Enabled = 0
// broke. Assert it here so the fix cannot be "quietly disable it after all".
using (var check = dbFactory.CreateDbContext())
{
check.ClusterNodes.Count(n => n.Enabled).ShouldBe(2);
}
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.NodeDeploymentStates.Select(s => s.NodeId).ShouldBe(new[] { "site-a-1:4053" });
}, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
}, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
}
private static void SeedNodes(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
params (string NodeId, bool Enabled, bool Maintenance)[] nodes)
{
using var db = dbFactory.CreateDbContext();
db.ServerClusters.Add(new ServerCluster
{
ClusterId = "SITE-A",
Name = "Site A",
Enterprise = "zb",
Site = "site-a",
NodeCount = 2,
RedundancyMode = RedundancyMode.Warm,
CreatedBy = "test",
});
foreach (var (nodeId, enabled, maintenance) in nodes)
{
db.ClusterNodes.Add(new ClusterNode
{
NodeId = nodeId,
ClusterId = "SITE-A",
Host = nodeId.Split(':')[0],
ApplicationUri = $"urn:OtOpcUa:{nodeId}",
Enabled = enabled,
MaintenanceMode = maintenance,
CreatedBy = "test",
});
}
db.SaveChanges();
}
private static DeploymentId SeedDispatchingDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
var id = DeploymentId.NewId();
using var db = dbFactory.CreateDbContext();
db.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = TestRevision.Value,
Status = DeploymentStatus.Dispatching,
CreatedBy = "test",
});
db.SaveChanges();
return id;
}
}