using Akka.Actor; using Akka.TestKit.Xunit2; using NSubstitute; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Communication.Actors; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Actors; /// /// T1A.3: the site communication actor delegates each of the seven site→central sends to the /// injected , preserving the current Sender as the reply /// target — and a transport failure surfaces to that sender exactly as the S&F / audit / health /// layers already expect, while a heartbeat transport fault never faults the actor. /// public class SiteCommunicationActorTransportTests : TestKit { private readonly CommunicationOptions _options = new(); private (IActorRef actor, ICentralTransport transport) NewActor() { var transport = Substitute.For(); var dmProbe = CreateTestProbe(); var actor = Sys.ActorOf(Props.Create(() => new SiteCommunicationActor("site1", _options, dmProbe.Ref, () => false, null, transport))); return (actor, transport); } [Fact] public void NotificationSubmit_DelegatesToTransport_WithSenderAsReplyTo() { var (actor, transport) = NewActor(); var submit = new NotificationSubmit( "notif-1", "Operators", "Subj", "Body", "site1", "inst1", "alarmScript", DateTimeOffset.UtcNow); actor.Tell(submit, TestActor); AwaitAssert(() => transport.Received(1).SubmitNotification( Arg.Is(m => m.NotificationId == "notif-1"), Arg.Is(TestActor))); } [Fact] public void NotificationStatusQuery_DelegatesToTransport_WithSenderAsReplyTo() { var (actor, transport) = NewActor(); actor.Tell(new NotificationStatusQuery("corr-1", "notif-1"), TestActor); AwaitAssert(() => transport.Received(1).QueryNotificationStatus( Arg.Is(m => m.NotificationId == "notif-1"), Arg.Is(TestActor))); } [Fact] public void IngestAuditEvents_DelegatesToTransport_WithSenderAsReplyTo() { var (actor, transport) = NewActor(); actor.Tell(new IngestAuditEventsCommand(new List()), TestActor); AwaitAssert(() => transport.Received(1).IngestAuditEvents(Arg.Any(), Arg.Is(TestActor))); } [Fact] public void IngestCachedTelemetry_DelegatesToTransport_WithSenderAsReplyTo() { var (actor, transport) = NewActor(); actor.Tell(new IngestCachedTelemetryCommand(new List()), TestActor); AwaitAssert(() => transport.Received(1).IngestCachedTelemetry(Arg.Any(), Arg.Is(TestActor))); } [Fact] public void ReconcileSite_DelegatesToTransport_WithSenderAsReplyTo() { var (actor, transport) = NewActor(); actor.Tell( new ReconcileSiteRequest("site1", "node-a", new Dictionary()), TestActor); AwaitAssert(() => transport.Received(1).ReconcileSite( Arg.Is(m => m.NodeId == "node-a"), Arg.Is(TestActor))); } [Fact] public void ReportSiteHealth_DelegatesToTransport_WithSenderAsReplyTo() { var (actor, transport) = NewActor(); var report = MinimalHealthReport(sequence: 7); actor.Tell(report, TestActor); AwaitAssert(() => transport.Received(1).ReportSiteHealth( Arg.Is(m => m.SequenceNumber == 7), Arg.Is(TestActor))); } [Fact] public void TransportFailureReply_RoutesBackToTheWaitingSender() { // The seam preserves the reply-routing the S&F layer depends on: when the transport // answers the captured replyTo with a Status.Failure (its transient-failure signal on a // non-OK status), that failure reaches the original sender — here the test actor — so the // waiting Ask faults, exactly as it did on the ClusterClient path. var transport = Substitute.For(); transport .When(t => t.SubmitNotification(Arg.Any(), Arg.Any())) .Do(ci => ci.Arg().Tell( new Status.Failure(new InvalidOperationException("central unavailable")))); var dmProbe = CreateTestProbe(); var actor = Sys.ActorOf(Props.Create(() => new SiteCommunicationActor("site1", _options, dmProbe.Ref, () => false, null, transport))); actor.Tell(new NotificationSubmit( "notif-x", "Operators", "S", "B", "site1", null, null, DateTimeOffset.UtcNow), TestActor); var failure = ExpectMsg(); Assert.IsType(failure.Cause); } [Fact] public void HeartbeatTransportThrow_DoesNotFaultTheActor() { // A transport whose SendHeartbeat throws must not fault the actor — heartbeats are // fire-and-forget and their failure is swallowed. Prove the actor still serves messages // after a heartbeat that threw. var transport = Substitute.For(); transport .When(t => t.SendHeartbeat(Arg.Any(), Arg.Any())) .Do(_ => throw new InvalidOperationException("boom")); var dmProbe = CreateTestProbe(); var actor = Sys.ActorOf(Props.Create(() => new SiteCommunicationActor( "site1", new CommunicationOptions { ApplicationHeartbeatInterval = TimeSpan.FromMilliseconds(50) }, dmProbe.Ref, () => true, null, transport))); // Let the heartbeat timer fire a few times (each throws inside the transport). Thread.Sleep(250); // The actor is still alive and delegating: a subsequent send is handled normally. actor.Tell(new NotificationSubmit( "after-heartbeat", "Operators", "S", "B", "site1", null, null, DateTimeOffset.UtcNow), TestActor); AwaitAssert(() => transport.Received(1).SubmitNotification( Arg.Is(m => m.NotificationId == "after-heartbeat"), Arg.Is(TestActor))); } private static SiteHealthReport MinimalHealthReport(long sequence) => new( SiteId: "site1", SequenceNumber: sequence, ReportTimestamp: DateTimeOffset.UtcNow, DataConnectionStatuses: new Dictionary(), TagResolutionCounts: new Dictionary(), ScriptErrorCount: 0, AlarmEvaluationErrorCount: 0, StoreAndForwardBufferDepths: new Dictionary(), DeadLetterCount: 0, DeployedInstanceCount: 0, EnabledInstanceCount: 0, DisabledInstanceCount: 0); }