using Akka.Actor; using Akka.TestKit; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; 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.AdminOperations; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase { /// Verifies that starting a deployment inserts a row and dispatches to the coordinator. [Fact] public void StartDeployment_inserts_deployment_and_dispatches_to_coordinator() { var dbFactory = NewInMemoryDbFactory(); var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); var dispatch = coordinator.ExpectMsg(TimeSpan.FromSeconds(3)); dispatch.DeploymentId.Value.ShouldNotBe(Guid.Empty); dispatch.RevisionHash.Value.Length.ShouldBe(64); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted); reply.DeploymentId.ShouldBe(dispatch.DeploymentId); reply.RevisionHash.ShouldBe(dispatch.RevisionHash); using var db = dbFactory.CreateDbContext(); var row = db.Deployments.Single(); row.Status.ShouldBe(DeploymentStatus.Dispatching); row.CreatedBy.ShouldBe("joe"); row.ArtifactBlob.Length.ShouldBeGreaterThan(0); db.ConfigEdits.Count().ShouldBe(1); db.ConfigEdits.Single().EntityType.ShouldBe("Deployment"); } /// Verifies the full DraftValidator gate (reject on ANY error): a Tag↔VirtualTag /// NodeId collision in the live config rejects the deploy (422-mapped /// ) before any coordinator dispatch — and inserts /// no Deployment row. The colliding equipment uses a canonical EquipmentId so the rejection is /// attributable to the collision rule, not to EquipmentIdNotDerived. [Fact] public void StartDeployment_rejects_on_Tag_VirtualTag_NodeId_collision() { var uuid = Guid.NewGuid(); var equipmentId = Configuration.Validation.DraftValidator.DeriveEquipmentId(uuid); var dbFactory = NewInMemoryDbFactory(); using (var db = dbFactory.CreateDbContext()) { db.Equipment.Add(new Configuration.Entities.Equipment { EquipmentUuid = uuid, EquipmentId = equipmentId, Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m", }); db.Tags.Add(new Configuration.Entities.Tag { TagId = "tag-speed", DriverInstanceId = "d", EquipmentId = equipmentId, Name = "speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }); db.VirtualTags.Add(new Configuration.Entities.VirtualTag { VirtualTagId = "vtag-speed", EquipmentId = equipmentId, Name = "speed", DataType = "Float", ScriptId = "s-1", }); db.SaveChanges(); } var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected); reply.Message.ShouldNotBeNull(); reply.Message.ShouldContain("EquipmentSignalNameCollision"); // the rule's error code reply.Message.ShouldContain("collide"); // the rule's message text using var verify = dbFactory.CreateDbContext(); verify.Deployments.Count().ShouldBe(0); } /// Verifies the full gate rejects a config whose Equipment carries a NON-canonical /// EquipmentId (not == DraftValidator.DeriveEquipmentId(uuid)): the deploy is /// with EquipmentIdNotDerived in the message, /// no coordinator dispatch, and no Deployment row. This is the rule the surgical gate used to /// let through and the reason the full activation was probed first. [Fact] public void StartDeployment_rejects_on_non_canonical_EquipmentId() { var dbFactory = NewInMemoryDbFactory(); using (var db = dbFactory.CreateDbContext()) { db.Equipment.Add(new Configuration.Entities.Equipment { EquipmentUuid = Guid.NewGuid(), EquipmentId = "EQ-operator-typed", // NOT derived from the UUID Name = "rinser-01", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m", }); db.SaveChanges(); } var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected); reply.Message.ShouldNotBeNull(); reply.Message.ShouldContain("EquipmentIdNotDerived"); using var verify = dbFactory.CreateDbContext(); verify.Deployments.Count().ShouldBe(0); } /// Verifies that a DriverInstance whose NamespaceId lives in a different cluster /// triggers BadCrossClusterNamespaceBinding and routes to the Rejected branch through /// the actor — no coordinator dispatch, no Deployment row. /// Seeded: Namespace in cluster "MAIN" + DriverInstance in cluster "SITE-A" referencing that /// namespace. NamespaceKind.Equipment + DriverType "ModbusTcp" satisfies the compat rule so /// only the cross-cluster rule fires. [Fact] public void StartDeployment_rejects_on_cross_cluster_namespace_binding() { const string nsId = "ns-main-equipment"; var dbFactory = NewInMemoryDbFactory(); using (var db = dbFactory.CreateDbContext()) { db.Namespaces.Add(new Configuration.Entities.Namespace { NamespaceId = nsId, ClusterId = "MAIN", Kind = Configuration.Enums.NamespaceKind.Equipment, NamespaceUri = "urn:zb:main:equipment", }); db.DriverInstances.Add(new Configuration.Entities.DriverInstance { DriverInstanceId = "drv-site-a-01", ClusterId = "SITE-A", NamespaceId = nsId, // cross-cluster: drv is SITE-A, ns is MAIN Name = "site-a-modbus", DriverType = "ModbusTcp", // compatible with Equipment ns — no compat error DriverConfig = "{}", }); db.SaveChanges(); } var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected); reply.Message.ShouldNotBeNull(); reply.Message.ShouldContain("BadCrossClusterNamespaceBinding"); using var verify = dbFactory.CreateDbContext(); verify.Deployments.Count().ShouldBe(0); } /// Verifies the warn-only compile-cost advisory: seeding N distinct non-passthrough /// (genuinely-compiled) Script rows yields an /// deploy whose carries a compile-cost advisory. /// The guardrail NEVER rejects — it only surfaces the estimated RSS pressure to the operator. [Fact] public void StartDeployment_warns_when_many_scripts_will_compile() { const int n = 10; var dbFactory = NewInMemoryDbFactory(); using (var db = dbFactory.CreateDbContext()) { for (var i = 0; i < n; i++) { db.Scripts.Add(new Configuration.Entities.Script { ScriptId = $"s-{i}", Name = $"script-{i}", SourceCode = $"return (int)ctx.GetTag(\"a\").Value + {i};", // distinct, non-passthrough SourceHash = $"hash-{i}", }); } db.SaveChanges(); } var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); // Still dispatches — the advisory is non-blocking. coordinator.ExpectMsg(TimeSpan.FromSeconds(3)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted); reply.Message.ShouldNotBeNull(); reply.Message.ShouldContain("will compile"); reply.Message.ShouldContain($"{n} script"); // the distinct compiled count using var verify = dbFactory.CreateDbContext(); verify.Deployments.Count().ShouldBe(1); } /// Verifies that passthrough "mirror" scripts (return ctx.GetTag("X").Value;) /// compile to ~nothing and therefore do NOT count toward the compile-cost advisory: a deploy /// of only passthrough scripts is with no advisory. [Fact] public void StartDeployment_passthrough_scripts_do_not_count() { var dbFactory = NewInMemoryDbFactory(); using (var db = dbFactory.CreateDbContext()) { for (var i = 0; i < 5; i++) { db.Scripts.Add(new Configuration.Entities.Script { ScriptId = $"mirror-{i}", Name = $"mirror-{i}", SourceCode = $"return ctx.GetTag(\"tag-{i}\").Value;", // passthrough mirror — costs ~nothing SourceHash = $"mhash-{i}", }); } db.SaveChanges(); } var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(3)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted); // No compiled scripts → no advisory emitted. (reply.Message is null || !reply.Message.Contains("will compile")).ShouldBeTrue(); } /// Verifies the advisory counts DISTINCT non-passthrough sources: many rows sharing one /// identical source collapse to a single compiled unit (the compile cache keys on source), so the /// advisory reports 1 — not the row count. [Fact] public void StartDeployment_duplicate_sources_collapse_to_one_compiled_unit() { const string identical = "return (int)ctx.GetTag(\"a\").Value + 1;"; var dbFactory = NewInMemoryDbFactory(); using (var db = dbFactory.CreateDbContext()) { for (var i = 0; i < 7; i++) { db.Scripts.Add(new Configuration.Entities.Script { ScriptId = $"dup-{i}", Name = $"dup-{i}", SourceCode = identical, // same source across all rows SourceHash = "dup-hash", }); } db.SaveChanges(); } var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(3)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted); reply.Message.ShouldNotBeNull(); reply.Message.ShouldContain("will compile"); reply.Message.ShouldContain("1 script(s) will compile"); } /// Verifies that starting a deployment is refused when another is in flight. [Fact] public void StartDeployment_refuses_when_another_is_in_flight() { var dbFactory = NewInMemoryDbFactory(); // Seed an in-flight Deployment. using (var db = dbFactory.CreateDbContext()) { db.Deployments.Add(new Configuration.Entities.Deployment { RevisionHash = new string('a', 64), Status = DeploymentStatus.Dispatching, CreatedBy = "earlier", }); db.SaveChanges(); } var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); reply.Outcome.ShouldBe(StartDeploymentOutcome.AnotherDeploymentInFlight); reply.DeploymentId.ShouldNotBeNull(); } }