using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Messages.Communication; using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.DebugView; using ScadaLink.Commons.Messages.Health; using ScadaLink.Communication.Actors; namespace ScadaLink.Communication.Tests; /// /// Tests for CentralCommunicationActor with database-driven site addressing. /// WP-4: Message routing via site address cache loaded from DB. /// WP-5: Connection failure and failover handling. /// 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 actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp))); return (actor, mockRepo); } private Site CreateSite(string identifier, string? nodeAPath, string? nodeBPath = null) => new("Test Site", identifier) { NodeAAddress = nodeAPath, NodeBAddress = nodeBPath }; [Fact] public void DatabaseDrivenRouting_RoutesToConfiguredSite() { var probe = CreateTestProbe(); var site = CreateSite("site1", probe.Ref.Path.ToString()); var (actor, _) = CreateActorWithMockRepo(new[] { site }); // Send explicit refresh and wait for async DB load + PipeTo actor.Tell(new RefreshSiteAddresses()); Thread.Sleep(1000); var command = new DeployInstanceCommand( "dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow); actor.Tell(new SiteEnvelope("site1", command)); probe.ExpectMsg(msg => msg.DeploymentId == "dep1"); } [Fact] public void UnconfiguredSite_MessageIsDropped() { var (actor, _) = CreateActorWithMockRepo(); actor.Tell(new RefreshSiteAddresses()); Thread.Sleep(1000); var command = new DeployInstanceCommand( "dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow); actor.Tell(new SiteEnvelope("unknown-site", command)); ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } [Fact] public void ConnectionLost_DebugStreamsKilled() { var siteProbe = CreateTestProbe(); var site = CreateSite("site1", siteProbe.Ref.Path.ToString()); var (actor, _) = CreateActorWithMockRepo(new[] { site }); actor.Tell(new RefreshSiteAddresses()); Thread.Sleep(1000); // Subscribe to debug view (tracks the subscription) var subscriberProbe = CreateTestProbe(); var subRequest = new SubscribeDebugViewRequest("inst1", "corr-123"); actor.Tell(new SiteEnvelope("site1", subRequest), subscriberProbe.Ref); // Simulate site disconnection actor.Tell(new ConnectionStateChanged("site1", false, DateTimeOffset.UtcNow)); // The subscriber should receive a DebugStreamTerminated notification subscriberProbe.ExpectMsg( msg => msg.SiteId == "site1" && msg.CorrelationId == "corr-123"); } [Fact] public void Heartbeat_ForwardedToParent() { // Actor still needs IServiceProvider even though this test doesn't use routing var mockRepo = Substitute.For(); mockRepo.GetAllSitesAsync(Arg.Any()) .Returns(new List()); var services = new ServiceCollection(); services.AddScoped(_ => mockRepo); var sp = services.BuildServiceProvider(); var parentProbe = CreateTestProbe(); var centralActor = parentProbe.ChildActorOf( Props.Create(() => new CentralCommunicationActor(sp))); var heartbeat = new HeartbeatMessage("site1", "host1", true, DateTimeOffset.UtcNow); centralActor.Tell(heartbeat); parentProbe.ExpectMsg(msg => msg.SiteId == "site1"); } [Fact] public void RefreshSiteAddresses_UpdatesCache() { var probe1 = CreateTestProbe(); var probe2 = CreateTestProbe(); var site1 = CreateSite("site1", probe1.Ref.Path.ToString()); var (actor, mockRepo) = CreateActorWithMockRepo(new[] { site1 }); // Wait for initial load, then send explicit refresh actor.Tell(new RefreshSiteAddresses()); Thread.Sleep(1000); // Verify routing to site1 works var cmd1 = new DeployInstanceCommand( "dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow); actor.Tell(new SiteEnvelope("site1", cmd1)); probe1.ExpectMsg(msg => msg.DeploymentId == "dep1"); // Update mock repo to return both sites var site2 = CreateSite("site2", probe2.Ref.Path.ToString()); mockRepo.GetAllSitesAsync(Arg.Any()) .Returns(new List { site1, site2 }); // Refresh again actor.Tell(new RefreshSiteAddresses()); Thread.Sleep(1000); // Verify routing to site2 now works var cmd2 = new DeployInstanceCommand( "dep2", "inst2", "hash2", "{}", "admin", DateTimeOffset.UtcNow); actor.Tell(new SiteEnvelope("site2", cmd2)); probe2.ExpectMsg(msg => msg.DeploymentId == "dep2"); } [Fact] public void NodeBFallback_WhenNodeANotConfigured() { var probe = CreateTestProbe(); var site = CreateSite("site1", null, probe.Ref.Path.ToString()); var (actor, _) = CreateActorWithMockRepo(new[] { site }); actor.Tell(new RefreshSiteAddresses()); Thread.Sleep(1000); var command = new DeployInstanceCommand( "dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow); actor.Tell(new SiteEnvelope("site1", command)); probe.ExpectMsg(msg => msg.DeploymentId == "dep1"); } }