using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
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.OpcUa;
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
{
///
/// Subscribes a probe to the cluster alarm-commands topic and waits for the
/// so the subscription is live before the actor under test
/// publishes. Returns the subscribed probe.
///
private TestProbe SubscribeAlarmCommandsProbe()
{
var probe = CreateTestProbe("alarm-cmds");
var mediator = DistributedPubSub.Get(Sys).Mediator;
// Send the Subscribe FROM the probe so the SubscribeAck routes back to it (the ack goes to the
// message sender, not to the subscribed ref). Mirrors ScriptedAlarmHostActor's self-subscribe.
probe.Send(mediator, new Subscribe(AlarmCommandsTopic.Name, probe.Ref));
probe.ExpectMsg(TimeSpan.FromSeconds(5));
return probe;
}
/// Verifies an publishes a correctly-mapped
/// (Operation="Acknowledge", AlarmId/User/Comment threaded, no
/// UnshelveAtUtc) onto the alarm-commands topic and replies Ok.
[Fact]
public void AcknowledgeAlarm_publishes_mapped_command_and_replies_ok()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty()));
var topicProbe = SubscribeAlarmCommandsProbe();
var correlationId = CorrelationId.NewId();
actor.Tell(new AcknowledgeAlarmCommand("alarm-42", "operator-jo", "looking into it", correlationId));
var published = topicProbe.ExpectMsg(TimeSpan.FromSeconds(3));
published.AlarmId.ShouldBe("alarm-42");
published.Operation.ShouldBe("Acknowledge");
published.User.ShouldBe("operator-jo");
published.Comment.ShouldBe("looking into it");
published.UnshelveAtUtc.ShouldBeNull();
var reply = ExpectMsg(TimeSpan.FromSeconds(3));
reply.Ok.ShouldBeTrue();
reply.Message.ShouldBeNull();
reply.CorrelationId.ShouldBe(correlationId);
}
/// Verifies a shelve maps to Operation="OneShotShelve"
/// with no UnshelveAtUtc and replies Ok.
[Fact]
public void ShelveAlarm_oneshot_publishes_OneShotShelve()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty()));
var topicProbe = SubscribeAlarmCommandsProbe();
var correlationId = CorrelationId.NewId();
actor.Tell(new ShelveAlarmCommand("alarm-7", "op-kim", ShelveKind.OneShot, UnshelveAtUtc: null, Comment: null, correlationId));
var published = topicProbe.ExpectMsg(TimeSpan.FromSeconds(3));
published.AlarmId.ShouldBe("alarm-7");
published.Operation.ShouldBe("OneShotShelve");
published.User.ShouldBe("op-kim");
published.UnshelveAtUtc.ShouldBeNull();
var reply = ExpectMsg(TimeSpan.FromSeconds(3));
reply.Ok.ShouldBeTrue();
reply.CorrelationId.ShouldBe(correlationId);
}
/// Verifies a shelve maps to Operation="TimedShelve" and
/// threads the UnshelveAtUtc through to the published command.
[Fact]
public void ShelveAlarm_timed_publishes_TimedShelve_with_unshelve_time()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty()));
var topicProbe = SubscribeAlarmCommandsProbe();
var unshelveAt = DateTime.UtcNow.AddMinutes(15);
actor.Tell(new ShelveAlarmCommand("alarm-9", "op-lee", ShelveKind.Timed, unshelveAt, Comment: "maint window", CorrelationId.NewId()));
var published = topicProbe.ExpectMsg(TimeSpan.FromSeconds(3));
published.AlarmId.ShouldBe("alarm-9");
published.Operation.ShouldBe("TimedShelve");
published.UnshelveAtUtc.ShouldBe(unshelveAt);
published.Comment.ShouldBe("maint window");
var reply = ExpectMsg(TimeSpan.FromSeconds(3));
reply.Ok.ShouldBeTrue();
}
/// Verifies a maps to Operation="Unshelve" with no
/// UnshelveAtUtc and replies Ok.
[Fact]
public void ShelveAlarm_unshelve_publishes_Unshelve()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty()));
var topicProbe = SubscribeAlarmCommandsProbe();
actor.Tell(new ShelveAlarmCommand("alarm-3", "op-sam", ShelveKind.Unshelve, UnshelveAtUtc: null, Comment: null, CorrelationId.NewId()));
var published = topicProbe.ExpectMsg(TimeSpan.FromSeconds(3));
published.Operation.ShouldBe("Unshelve");
published.UnshelveAtUtc.ShouldBeNull();
ExpectMsg(TimeSpan.FromSeconds(3)).Ok.ShouldBeTrue();
}
/// Verifies a shelve with a null UnshelveAtUtc is rejected
/// at the singleton (no publish) with an attributable failure — the engine requires the instant.
[Fact]
public void ShelveAlarm_timed_without_unshelve_time_is_rejected_and_not_published()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty()));
var topicProbe = SubscribeAlarmCommandsProbe();
actor.Tell(new ShelveAlarmCommand("alarm-1", "op-zoe", ShelveKind.Timed, UnshelveAtUtc: null, Comment: null, CorrelationId.NewId()));
var reply = ExpectMsg(TimeSpan.FromSeconds(3));
reply.Ok.ShouldBeFalse();
reply.Message.ShouldNotBeNull();
reply.Message.ShouldContain("UnshelveAtUtc");
// No command should have been published.
topicProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// 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 v3 UNS effective-name
/// collision — a UnsTagReference (surfacing a raw Tag under an equipment) and a VirtualTag sharing the
/// same effective leaf name in that equipment's UNS NodeId space — rejects the deploy (422-mapped
/// ) before any coordinator dispatch, inserting 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_UNS_effective_name_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",
UnsLineId = "line-a",
MachineCode = "m",
});
// v3: a raw Tag binds to a Device; it reaches the equipment's UNS space via a UnsTagReference.
db.DriverInstances.Add(new Configuration.Entities.DriverInstance
{
DriverInstanceId = "d", ClusterId = "", Name = "drv", DriverType = "Modbus", DriverConfig = "{}",
});
db.Devices.Add(new Configuration.Entities.Device
{
DeviceId = "dev", DriverInstanceId = "d", Name = "dev", DeviceConfig = "{}",
});
db.Tags.Add(new Configuration.Entities.Tag
{
TagId = "tag-speed",
DeviceId = "dev",
Name = "speed",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.UnsTagReferences.Add(new Configuration.Entities.UnsTagReference
{
UnsTagReferenceId = "ref-speed", EquipmentId = equipmentId, TagId = "tag-speed",
});
// A VirtualTag whose Name collides with the reference's effective name "speed".
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("UnsEffectiveNameCollision"); // the v3 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",
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);
}
// RETIRED (v3): the Namespace entity and its cluster-binding rule (BadCrossClusterNamespaceBinding)
// are deleted — the two OPC UA namespaces (Raw/UNS) are implicit and driver instances no longer bind a
// Namespace. There is no v3 analog for a cross-cluster namespace binding, so this deploy-gate case is
// dropped (per the v3 schema migration). Cluster scoping is now exercised via the UNS line→area→cluster
// attribution covered in DeploymentArtifactTests.
/// 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 the cluster-topology guard is wired into the deploy gate (Configuration-013):
/// a cluster with only ONE enabled
/// (the second toggled off) is with the
/// ClusterEnabledNodeCountMismatch topology error in the message — no coordinator dispatch,
/// no Deployment row. The row-level SQL CHECK cannot see the disabled-node flag, so this proves the
/// managed guard runs
/// at deploy time rather than sitting inert.
[Fact]
public void StartDeployment_rejects_on_invalid_cluster_topology_disabled_node()
{
var dbFactory = NewInMemoryDbFactory();
using (var db = dbFactory.CreateDbContext())
{
db.ServerClusters.Add(new Configuration.Entities.ServerCluster
{
ClusterId = "LINE3-OPCUA",
Name = "Line 3",
Enterprise = "zb",
Site = "dev",
NodeCount = 2,
RedundancyMode = RedundancyMode.Hot, // declared 2 + Hot, but only 1 enabled below
CreatedBy = "seed",
});
db.ClusterNodes.Add(new Configuration.Entities.ClusterNode
{
NodeId = "LINE3-OPCUA-A",
ClusterId = "LINE3-OPCUA",
Host = "host-a",
ApplicationUri = "urn:line3:a",
Enabled = true,
CreatedBy = "seed",
});
db.ClusterNodes.Add(new Configuration.Entities.ClusterNode
{
NodeId = "LINE3-OPCUA-B",
ClusterId = "LINE3-OPCUA",
Host = "host-b",
ApplicationUri = "urn:line3:b",
Enabled = false, // toggled off → effective enabled-count = 1 while mode stays Hot
CreatedBy = "seed",
});
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("ClusterEnabledNodeCountMismatch");
using var verify = dbFactory.CreateDbContext();
verify.Deployments.Count().ShouldBe(0);
}
/// Verifies the topology guard does NOT spuriously reject a well-formed cluster: a
/// cluster whose two s are both enabled
/// passes the topology check, so a deploy of an otherwise-valid config is
/// with no topology error in the message and a row
/// inserted. Pairs with the rejecting test to prove the guard is discriminating, not blanket.
[Fact]
public void StartDeployment_accepts_when_cluster_topology_is_valid()
{
var dbFactory = NewInMemoryDbFactory();
using (var db = dbFactory.CreateDbContext())
{
db.ServerClusters.Add(new Configuration.Entities.ServerCluster
{
ClusterId = "LINE3-OPCUA",
Name = "Line 3",
Enterprise = "zb",
Site = "dev",
NodeCount = 2,
RedundancyMode = RedundancyMode.Hot,
CreatedBy = "seed",
});
db.ClusterNodes.Add(new Configuration.Entities.ClusterNode
{
NodeId = "LINE3-OPCUA-A",
ClusterId = "LINE3-OPCUA",
Host = "host-a",
ApplicationUri = "urn:line3:a",
Enabled = true,
CreatedBy = "seed",
});
db.ClusterNodes.Add(new Configuration.Entities.ClusterNode
{
NodeId = "LINE3-OPCUA-B",
ClusterId = "LINE3-OPCUA",
Host = "host-b",
ApplicationUri = "urn:line3:b",
Enabled = true, // both enabled → matches declared NodeCount=2 + Hot
CreatedBy = "seed",
});
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 is null || !reply.Message.Contains("ClusterEnabledNodeCountMismatch")).ShouldBeTrue();
(reply.Message is null || !reply.Message.Contains("ClusterRedundancyModeInvalid")).ShouldBeTrue();
using var verify = dbFactory.CreateDbContext();
verify.Deployments.Count().ShouldBe(1);
}
/// 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();
}
}