using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Communication.Actors; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using Akka.TestKit; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// /// Tests for behaviour that is independent of the /// central→site command transport: heartbeat/replica aggregation, notification-outbox proxying, /// and the DB-refresh failure path. The per-site routing itself (envelope → transport → the right /// site) is the transport's job and is covered by /// and the GrpcSiteTransport suites; /// the old ClusterClient-routing tests here were removed with the Akka transport in the /// ClusterClient→gRPC migration's Phase 4. /// public class CentralCommunicationActorTests : TestKit { public CentralCommunicationActorTests() : base(@"akka.loglevel = DEBUG") { } private (IActorRef actor, ISiteRepository mockRepo) CreateActorWithMockRepo( IEnumerable? sites = null) { var mockRepo = Substitute.For(); mockRepo.GetAllSitesAsync(Arg.Any()) .Returns(sites?.ToList() ?? new List()); var services = new ServiceCollection(); services.AddScoped(_ => mockRepo); var sp = services.BuildServiceProvider(); var transport = Substitute.For(); var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null))); return (actor, mockRepo); } [Fact] public void Heartbeat_BumpsAggregatorTimestamp() { var mockRepo = Substitute.For(); mockRepo.GetAllSitesAsync(Arg.Any()) .Returns(new List()); var aggregator = Substitute.For(); var services = new ServiceCollection(); services.AddScoped(_ => mockRepo); services.AddSingleton(aggregator); var sp = services.BuildServiceProvider(); var transport = Substitute.For(); var centralActor = Sys.ActorOf( Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null))); var timestamp = DateTimeOffset.UtcNow; centralActor.Tell(new HeartbeatMessage("site1", "host1", true, timestamp)); AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site1", timestamp)); } [Fact] public void HeartbeatReplica_MarksLocalAggregator_WithoutRebroadcast() { // Task 16 (arch review 02): a heartbeat replicated from the peer central // node marks the local aggregator (so post-failover both nodes know the // site's liveness) but is NOT re-published (it arrives via pub-sub already). var mockRepo = Substitute.For(); mockRepo.GetAllSitesAsync(Arg.Any()) .Returns(new List()); var aggregator = Substitute.For(); var services = new ServiceCollection(); services.AddScoped(_ => mockRepo); services.AddSingleton(aggregator); var sp = services.BuildServiceProvider(); var transport = Substitute.For(); var centralActor = Sys.ActorOf( Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null))); var ts = DateTimeOffset.UtcNow; centralActor.Tell(new SiteHeartbeatReplica(new HeartbeatMessage("site-1", "host-a", true, ts))); AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", ts)); } [Fact] public void LoadSiteAddressesFailure_IsLoggedNotSilentlySwallowed() { // Regression test for Communication-006. When the repository query throws, // PipeTo delivers a Status.Failure to the actor. Without a Receive // handler the failure becomes an unhandled message (debug-level only) and the // periodic refresh fails silently — operators cannot tell "no addresses // configured" from "database is down". The fix logs the failure at Warning. var mockRepo = Substitute.For(); mockRepo.GetAllSitesAsync(Arg.Any()) .Returns>>(_ => throw new InvalidOperationException("database is down")); var services = new ServiceCollection(); services.AddScoped(_ => mockRepo); var sp = services.BuildServiceProvider(); var transport = Substitute.For(); // The fix logs a Warning carrying the InvalidOperationException as the cause. EventFilter.Warning(contains: "Failed to load site addresses from the database").ExpectOne(() => { Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null))); }); } private NotificationSubmit CreateSubmit(string id = "notif1") => new(id, "ops-list", "Subject", "Body", "site1", "inst1", "script.cs", DateTimeOffset.UtcNow); [Fact] public void NotificationSubmit_ForwardedToOutboxProxy_AckRoutesBackToSite() { var (actor, _) = CreateActorWithMockRepo(); var outboxProbe = CreateTestProbe(); actor.Tell(new RegisterNotificationOutbox(outboxProbe.Ref)); // A second probe stands in for the site (the original Sender). var siteProbe = CreateTestProbe(); var submit = CreateSubmit(); actor.Tell(submit, siteProbe.Ref); // The outbox proxy receives the NotificationSubmit with the site as the sender, // so an ack it sends routes straight back to the site, not the central actor. outboxProbe.ExpectMsg(m => m.NotificationId == "notif1"); outboxProbe.Reply(new NotificationSubmitAck("notif1", Accepted: true, Error: null)); siteProbe.ExpectMsg(a => a.NotificationId == "notif1" && a.Accepted); } [Fact] public void NotificationStatusQuery_ForwardedToOutboxProxy_ResponseRoutesBackToSite() { var (actor, _) = CreateActorWithMockRepo(); var outboxProbe = CreateTestProbe(); actor.Tell(new RegisterNotificationOutbox(outboxProbe.Ref)); var siteProbe = CreateTestProbe(); var query = new NotificationStatusQuery("corr1", "notif1"); actor.Tell(query, siteProbe.Ref); outboxProbe.ExpectMsg(m => m.CorrelationId == "corr1"); outboxProbe.Reply(new NotificationStatusResponse( "corr1", Found: true, Status: "Delivered", RetryCount: 0, LastError: null, DeliveredAt: DateTimeOffset.UtcNow)); siteProbe.ExpectMsg(r => r.CorrelationId == "corr1" && r.Found); } [Fact] public void NotificationSubmit_NoOutboxConfigured_RepliesNonAccepted() { var (actor, _) = CreateActorWithMockRepo(); // No RegisterNotificationOutbox sent — the proxy is null. var submit = CreateSubmit(); actor.Tell(submit); var ack = ExpectMsg(); Assert.Equal("notif1", ack.NotificationId); Assert.False(ack.Accepted); Assert.NotNull(ack.Error); } [Fact] public void NotificationStatusQuery_NoOutboxConfigured_RepliesNotFound() { var (actor, _) = CreateActorWithMockRepo(); // No RegisterNotificationOutbox sent — the proxy is null. var query = new NotificationStatusQuery("corr1", "notif1"); actor.Tell(query); var response = ExpectMsg(); Assert.Equal("corr1", response.CorrelationId); Assert.False(response.Found); } }