using System.Collections.Immutable; using Akka.Actor; using Akka.Cluster.Tools.Client; using Akka.Configuration; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Xunit; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Communication.Actors; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; public class CentralCommunicationActorClientLifecycleTests : TestKit { private static readonly Config TestConfig = ConfigurationFactory.ParseString(@" akka.actor.provider = cluster akka.remote.dot-netty.tcp.port = 0 akka.remote.dot-netty.tcp.hostname = localhost") .WithFallback(ClusterClientReceptionist.DefaultConfig()); public CentralCommunicationActorClientLifecycleTests() : base(TestConfig) { } // Empty ServiceProvider with a no-op ISiteRepository so the actor's periodic // db-refresh (fired at PreStart) resolves and returns no sites, keeping the // logs clean; the tests drive SiteAddressCacheLoaded directly. private static IServiceProvider EmptyProvider() { var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync(Arg.Any()).Returns(new List()); var services = new ServiceCollection(); services.AddScoped(_ => siteRepo); return services.BuildServiceProvider(); } private static SiteAddressCacheLoaded Load(string siteId, params string[] addrs) => new(new Dictionary> { [siteId] = addrs.ToList().AsReadOnly() }, new[] { siteId }); [Fact] public void PeriodicRefresh_PrunesDeletedSites_FromHealthAggregator() { // PLAN-01 Task 18: the periodic site refresh feeds the currently-configured // site ids to the aggregator so a deleted site is evicted within one // refresh interval. Repo returns only "site-a". var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(new List { new("Site A", "site-a") }); var aggregator = Substitute.For(); var services = new ServiceCollection(); services.AddScoped(_ => siteRepo); services.AddSingleton(aggregator); var provider = services.BuildServiceProvider(); var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor( provider, new DefaultSiteClientFactory(), null))); // Trigger the refresh (also fires at PreStart, but drive it explicitly so // the assertion is deterministic). The load runs on a detached task and // pipes SiteAddressCacheLoaded back to the actor, which then prunes. actor.Tell(new RefreshSiteAddresses()); AwaitAssert( () => aggregator.Received().PruneUnknownSites( Arg.Is>(ids => ids.Contains("site-a"))), TimeSpan.FromSeconds(3)); } [Fact] public void AddressEdit_RecreatesClient_WithoutRestartLoop() { var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor( EmptyProvider(), new DefaultSiteClientFactory(), null))); // First load creates the client; second load (edited NodeA address) stops // the old one and creates a replacement in the same message handling. // Pre-fix this throws InvalidActorNameException and restarts the actor. EventFilter.Exception().Expect(0, () => { actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081")); actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a-edited:8081")); actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081")); // and back — third generation }); // The actor must still be alive and routing (not crash-looping): // an envelope for an unknown site produces the "No ClusterClient" warning, // proving the Receive pipeline is healthy. EventFilter.Warning(contains: "No ClusterClient for site").ExpectOne(() => actor.Tell(new SiteEnvelope("unknown-site", new object()))); } [Fact] public void FactoryThrow_SkipsSite_DoesNotCrashActor() { var throwingFactory = Substitute.For(); throwingFactory.Create(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(_ => throw new InvalidOperationException("boom")); var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor( EmptyProvider(), throwingFactory, null))); EventFilter.Error(contains: "Failed to create ClusterClient").ExpectOne(() => actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081"))); // Actor survived — a subsequent envelope for that (unrouted) site still // produces the healthy "No ClusterClient" warning rather than a dead actor. EventFilter.Warning(contains: "No ClusterClient for site").ExpectOne(() => actor.Tell(new SiteEnvelope("site-a", new object()))); } }