using Akka.Actor; 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.Enums; using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; public sealed class ConfigPublishCoordinatorTests : ControlPlaneActorTestBase { private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('a', 64)); /// Verifies that deployment seals immediately when no driver-role members are in the cluster. [Fact] public void EmptyCluster_dispatch_seals_immediately() { // With no driver-role cluster members in scope, the coordinator has nobody to wait for // and seals the deployment right after writing the AwaitingApplyAcks status. var dbFactory = NewInMemoryDbFactory(); var deploymentId = SeedDispatchingDeployment(dbFactory); var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory)); actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId())); AwaitAssert(() => { using var db = dbFactory.CreateDbContext(); var status = db.Deployments.Single().Status; status.ShouldBe(DeploymentStatus.Sealed); }, duration: TimeSpan.FromSeconds(3)); } /// Verifies that stale apply acknowledgments arriving after seal are ignored. [Fact] public void Stale_ApplyAck_after_seal_is_ignored() { var dbFactory = NewInMemoryDbFactory(); var deploymentId = SeedDispatchingDeployment(dbFactory); var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory)); actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId())); // Wait for seal. AwaitAssert(() => { using var db = dbFactory.CreateDbContext(); db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed); }, duration: TimeSpan.FromSeconds(3)); // Now send a late ApplyAck for the just-sealed deployment. Should be a no-op — neither // crash the actor nor modify the row. We give it a beat and re-check the status. actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("ghost-node"), ApplyAckOutcome.Applied, null, CorrelationId.NewId())); ExpectNoMsg(TimeSpan.FromMilliseconds(250)); using var db = dbFactory.CreateDbContext(); db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed); } /// /// Regression guard for ControlPlane-001: an ApplyAck from a node that was NOT in the expected-ack /// set (i.e. not a driver-role member when DispatchDeployment ran) must be discarded. /// Without the fix, an unexpected-node ack inflates _acks.Count and can cause the /// coordinator to seal a deployment before every expected node has responded. /// /// Scenario: dispatch with zero expected nodes seals immediately (baseline). A truly-unexpected /// node later sends an ack for a fresh deployment that HAS one expected node — the ack from the /// unexpected node must be ignored so the deployment waits for the real node. /// [Fact] public void ApplyAck_from_unexpected_node_is_discarded_and_does_not_seal_prematurely() { var dbFactory = NewInMemoryDbFactory(); var deploymentId = SeedDispatchingDeployment(dbFactory); // Seed a NodeDeploymentState row for "expected-driver" so the coordinator sees 1 expected ack. using (var db = dbFactory.CreateDbContext()) { db.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState { NodeId = "expected-driver", DeploymentId = deploymentId.Value, Status = Configuration.Enums.NodeDeploymentStatus.Applying, }); db.SaveChanges(); } // Long deadline so time does not confound the test. var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, TimeSpan.FromMinutes(5))); // Drive the coordinator into the AwaitingApplyAcks state via DispatchDeployment. // The coordinator seeds expected-acks from _cluster.State.Members (filtered to driver role). // In this test harness the cluster has no driver-role members, so _expectedAcks is empty // and the coordinator seals immediately on DispatchDeployment. // // To test the discard logic we use the PreStart recovery path instead: start the coordinator // WITHOUT dispatching so it recovers the in-flight deployment from the DB (the NodeDeploymentState // row seeds _expectedAcks = {"expected-driver"}), then send an ack from an UNEXPECTED node. // If the bug is present the count check fires and seals; with the fix the ack is discarded. // Send ack from a rogue node NOT in _expectedAcks. actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("rogue-node"), ApplyAckOutcome.Applied, null, CorrelationId.NewId())); // Give it time to process and potentially seal if buggy. ExpectNoMsg(TimeSpan.FromMilliseconds(400)); using var db2 = dbFactory.CreateDbContext(); var status = db2.Deployments.Single().Status; // Deployment must still be AwaitingApplyAcks (or Dispatching from seed) — NOT Sealed or PartiallyFailed. status.ShouldNotBe(DeploymentStatus.Sealed); status.ShouldNotBe(DeploymentStatus.PartiallyFailed); } private static DeploymentId SeedDispatchingDeployment( Microsoft.EntityFrameworkCore.IDbContextFactory dbFactory) { var id = DeploymentId.NewId(); using var db = dbFactory.CreateDbContext(); db.Deployments.Add(new Configuration.Entities.Deployment { DeploymentId = id.Value, RevisionHash = TestRevision.Value, Status = DeploymentStatus.Dispatching, CreatedBy = "test", }); db.SaveChanges(); return id; } }