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.Enums; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; public sealed class DriverHostActorTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); [Fact] public void Bootstrap_with_no_prior_state_enters_Steady() { var db = NewInMemoryDbFactory(); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props(db, TestNode, coordinator.Ref)); // No-rev Steady: an incoming dispatch should be processed as a fresh apply, not a no-op. var deploymentId = SeedDeployment(db, RevA, DeploymentStatus.Sealed); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); var ack = coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); ack.Outcome.ShouldBe(ApplyAckOutcome.Applied); ack.NodeId.ShouldBe(TestNode); } [Fact] public void Same_revision_dispatch_is_acked_immediately_with_no_apply_work() { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeployment(db, RevA, DeploymentStatus.Sealed); // Seed an Applied NodeDeploymentState for self at RevA so PreStart recovers Steady@RevA. using (var ctx = db.CreateDbContext()) { ctx.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState { NodeId = TestNode.Value, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applied, AppliedAtUtc = DateTime.UtcNow.AddMinutes(-1), }); ctx.SaveChanges(); } var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props(db, TestNode, coordinator.Ref)); // Dispatch the SAME deployment again. actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); var ack = coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); ack.Outcome.ShouldBe(ApplyAckOutcome.Applied); // No new NodeDeploymentState row got added — the rev matched, so nothing changed. using var verify = db.CreateDbContext(); verify.NodeDeploymentStates.Count(s => s.NodeId == TestNode.Value).ShouldBe(1); } [Fact] public void New_revision_dispatch_writes_Applied_NodeDeploymentState() { var db = NewInMemoryDbFactory(); var deploymentB = SeedDeployment(db, RevB, DeploymentStatus.Dispatching); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props(db, TestNode, coordinator.Ref)); actor.Tell(new DispatchDeployment(deploymentB, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); AwaitAssert(() => { using var verify = db.CreateDbContext(); var row = verify.NodeDeploymentStates.Single(s => s.NodeId == TestNode.Value && s.DeploymentId == deploymentB.Value); row.Status.ShouldBe(NodeDeploymentStatus.Applied); row.AppliedAtUtc.ShouldNotBeNull(); }, duration: TimeSpan.FromSeconds(3)); } [Fact] public void Orphan_Applying_row_on_bootstrap_replays_apply() { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeployment(db, RevA, DeploymentStatus.AwaitingApplyAcks); // Crash-orphan: a prior actor was mid-apply and never finished. using (var ctx = db.CreateDbContext()) { ctx.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState { NodeId = TestNode.Value, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applying, StartedAtUtc = DateTime.UtcNow.AddMinutes(-2), }); ctx.SaveChanges(); } var coordinator = CreateTestProbe(); Sys.ActorOf(DriverHostActor.Props(db, TestNode, coordinator.Ref)); // PreStart should replay → ApplyAck back to coordinator with the new correlation id. var ack = coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); ack.DeploymentId.ShouldBe(deploymentId); ack.Outcome.ShouldBe(ApplyAckOutcome.Applied); using var verify = db.CreateDbContext(); verify.NodeDeploymentStates.Single(s => s.NodeId == TestNode.Value && s.DeploymentId == deploymentId.Value) .Status.ShouldBe(NodeDeploymentStatus.Applied); } private static DeploymentId SeedDeployment( IDbContextFactory db, RevisionHash rev, DeploymentStatus status) { var id = DeploymentId.NewId(); using var ctx = db.CreateDbContext(); ctx.Deployments.Add(new Configuration.Entities.Deployment { DeploymentId = id.Value, RevisionHash = rev.Value, Status = status, CreatedBy = "test", SealedAtUtc = status == DeploymentStatus.Sealed ? DateTime.UtcNow : null, }); ctx.SaveChanges(); return id; } }