using Akka.Actor; using Akka.Cluster.Tools.Client; 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.Messages.Mesh; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Runtime.Communication; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Communication; public class NodeCommunicationActorTests : RuntimeActorTestBase { private static DispatchDeployment NewDispatch() => new( new DeploymentId(Guid.NewGuid()), new RevisionHash("rev-1"), new CorrelationId(Guid.NewGuid())); [Fact] public void DispatchDeployment_from_central_lands_on_the_node_local_event_stream() { var subscriber = CreateTestProbe(); Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment)); var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); var dispatch = NewDispatch(); actor.Tell(dispatch); subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(dispatch); } [Fact] public void A_command_is_published_exactly_once() { // The reason inbound commands do NOT go back onto their DPS topic: central SendToAll-s to // every node's comm actor, so a mesh-wide republish would deliver N copies to every // subscriber. One publish per inbound command is the whole point. var subscriber = CreateTestProbe(); Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment)); var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); actor.Tell(NewDispatch()); subscriber.ExpectMsg(TimeSpan.FromSeconds(5)); subscriber.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } [Fact] public void RestartDriver_from_central_lands_on_the_event_stream() { var subscriber = CreateTestProbe(); Sys.EventStream.Subscribe(subscriber.Ref, typeof(RestartDriver)); var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); var msg = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid()); actor.Tell(msg); subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(msg); } [Fact] public void ReconnectDriver_from_central_lands_on_the_event_stream() { var subscriber = CreateTestProbe(); Sys.EventStream.Subscribe(subscriber.Ref, typeof(ReconnectDriver)); var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); var msg = new ReconnectDriver("C1", "driver-1", "alice", Guid.NewGuid()); actor.Tell(msg); subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(msg); } [Fact] public void AlarmCommand_from_central_lands_on_the_event_stream() { // ScriptedAlarmHostActor is a CHILD of DriverHostActor with no registry key, so there is no // ref to hand this actor at wiring time — the EventStream is what makes the alarm-command // leg possible without a registration handshake. var subscriber = CreateTestProbe(); Sys.EventStream.Subscribe(subscriber.Ref, typeof(AlarmCommand)); var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); var msg = new AlarmCommand("alarm-1", "Acknowledge", "alice", "comment", null); actor.Tell(msg); subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(msg); } [Fact] public void ApplyAck_goes_to_central_as_a_Send_not_a_SendToAll() { // Send, not SendToAll: the deploy coordinator is one singleton behind the central comm // actor's proxy. The coordinator's NodeId-keyed upsert absorbs a duplicate idempotently, // so the single-target rule is defence in depth and a bound on wasted work, not a // correctness requirement. var client = CreateTestProbe(); var actor = Sys.ActorOf(NodeCommunicationActor.Props(client.Ref)); var ack = new ApplyAck( new DeploymentId(Guid.NewGuid()), new NodeId("host-a:4053"), ApplyAckOutcome.Applied, null, new CorrelationId(Guid.NewGuid())); actor.Tell(ack); var sent = client.ExpectMsg(TimeSpan.FromSeconds(5)); sent.Path.ShouldBe(MeshPaths.CentralCommunication); sent.Message.ShouldBe(ack); } [Fact] public void An_ack_with_no_central_client_is_dropped_with_a_warning() { var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); var ack = new ApplyAck( new DeploymentId(Guid.NewGuid()), new NodeId("host-a:4053"), ApplyAckOutcome.Applied, null, new CorrelationId(Guid.NewGuid())); EventFilter.Warning(contains: "dropping ApplyAck").ExpectOne( TimeSpan.FromSeconds(5), () => actor.Tell(ack)); } // ---- Mode=Grpc (ClusterClient→gRPC migration Task 1B.2) ------------------------------------- private static ApplyAck NewAck() => new( new DeploymentId(Guid.NewGuid()), new NodeId("host-a:4053"), ApplyAckOutcome.Applied, null, new CorrelationId(Guid.NewGuid())); [Fact] public void Under_Grpc_the_ack_goes_to_the_sender_exactly_once() { // The gRPC counterpart of the ClusterClient.Send fact above, and it carries the same // single-count obligation: one post per node per dispatch, because both central endpoints // route to the SAME coordinator singleton proxy. Where the ack goes among the endpoints is the // sender's business (GrpcApplyAckSenderTests); what this pins is that the actor hands it over // once and does not also fan it out itself. var sender = new RecordingAckSender(); var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null, ackSender: sender)); var ack = NewAck(); actor.Tell(ack); AwaitAssert(() => sender.Sent.Count.ShouldBe(1), TimeSpan.FromSeconds(5)); sender.Sent[0].ShouldBe(ack); ExpectNoMsg(TimeSpan.FromMilliseconds(300)); sender.Sent.Count.ShouldBe(1); } [Fact] public void A_sender_wins_over_a_cluster_client_and_the_ack_is_not_sent_twice() { // Defence in depth. The registration arm builds exactly one transport — under Grpc no // ClusterClient is created at all — and if both were ever present, posting over both would // deliver two acks for one apply. The coordinator would absorb the duplicate (NodeId-keyed // upsert), so this pins tidiness and bounded work, not seal correctness. var client = CreateTestProbe(); var sender = new RecordingAckSender(); var actor = Sys.ActorOf(NodeCommunicationActor.Props(client.Ref, sender)); actor.Tell(NewAck()); AwaitAssert(() => sender.Sent.Count.ShouldBe(1), TimeSpan.FromSeconds(5)); client.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } [Fact] public void The_ClusterClient_branch_never_touches_the_sender() { // Null-safe by construction: under Dps and ClusterClient the sender is not built at all, so the // ApplyAck path must not depend on one existing. var client = CreateTestProbe(); var actor = Sys.ActorOf(NodeCommunicationActor.Props(client.Ref)); var ack = NewAck(); actor.Tell(ack); client.ExpectMsg(TimeSpan.FromSeconds(5)).Message.ShouldBe(ack); } [Fact] public void Inbound_commands_are_unaffected_by_the_presence_of_a_sender() { // The sender is outbound-only. A Grpc node still re-emits central's commands on its own // EventStream exactly as every other mode does. var subscriber = CreateTestProbe(); Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment)); var sender = new RecordingAckSender(); var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null, ackSender: sender)); var dispatch = NewDispatch(); actor.Tell(dispatch); subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(dispatch); sender.Sent.ShouldBeEmpty(); } /// An double recording what the actor handed it. private sealed class RecordingAckSender : IApplyAckSender { private readonly Lock _gate = new(); private readonly List _sent = []; /// Gets a snapshot of the acks handed to this sender. public IReadOnlyList Sent { get { lock (_gate) return [.. _sent]; } } /// Records . /// The ack the actor handed over. /// Unused. /// A completed task — the real sender never throws either. public Task SendAsync(ApplyAck ack, CancellationToken cancellationToken = default) { lock (_gate) _sent.Add(ack); return Task.CompletedTask; } } }