using Akka.Actor; using Akka.TestKit.Xunit2; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// /// Review 2026-10-21 (lane A M2): a data connection name becomes its DataConnectionActor's /// name. The old sanitiser kept non-ASCII letters and '$' (both refused by Akka) and mapped /// "PLC A" and "PLC-A" to the same name; either made Context.ActorOf throw on the /// manager's own thread, restarting it and dropping every live connection. /// [Collection("DataConnectionManagerActor")] public class ConnectionActorNameTests : TestKit { private readonly IDataConnectionFactory _factory = Substitute.For(); private readonly ISiteHealthCollector _health = Substitute.For(); private volatile bool _slowDispose; private readonly DataConnectionOptions _options = new() { ReconnectInterval = TimeSpan.FromMilliseconds(100), TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200) }; /// Creates the test kit with a factory that hands out connected mock adapters. public ConnectionActorNameTests() { _factory.Create(Arg.Any(), Arg.Any>()).Returns(_ => { var adapter = Substitute.For(); adapter.ConnectAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); adapter.Status.Returns(ConnectionHealth.Connected); adapter.SubscribeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns("sub-1"); // A synchronous delay inside DisposeAsync holds the stopping child in PostStop, which // keeps its actor name reserved — the dispose window the L2 tests exercise. adapter.DisposeAsync().Returns(_ => { if (_slowDispose) Thread.Sleep(400); return ValueTask.CompletedTask; }); return adapter; }); } /// Every sanitised name is a legal Akka path element, including non-ASCII and '$' input. [Theory] [InlineData("PLC A")] [InlineData("Ofen Süd")] [InlineData("$PLC")] [InlineData("名前")] [InlineData("a/b#c[d]%e")] [InlineData("")] public void Sanitize_AlwaysYieldsAValidActorName(string connectionName) { Assert.True(ActorPath.IsValidPathElement(ConnectionActorNames.Sanitize(connectionName))); } private IActorRef CreateManager() => Sys.ActorOf(Props.Create(() => new DataConnectionManagerActor(_factory, _options, _health))); private SubscribeTagsResponse Subscribe(IActorRef manager, string connection) { manager.Tell(new SubscribeTagsRequest( Guid.NewGuid().ToString("N"), "inst1", connection, ["tag1"], DateTimeOffset.UtcNow)); return ExpectMsg(TimeSpan.FromSeconds(10)); } /// A non-ASCII connection name is created instead of crashing the manager. [Fact] public void NonAsciiConnectionName_IsCreated() { var manager = CreateManager(); manager.Tell(new CreateConnectionCommand("Ofen Süd", "OpcUa", new Dictionary(), null, 3)); var response = Subscribe(manager, "Ofen Süd"); Assert.DoesNotContain("Unknown connection", response.ErrorMessage ?? ""); } /// /// Two names that sanitise alike: the second fails alone; the first — and the manager — /// keep working. /// [Fact] public void CollidingSanitisedNames_FailOnlyTheLaterConnection() { var manager = CreateManager(); manager.Tell(new CreateConnectionCommand("PLC A", "OpcUa", new Dictionary(), null, 3)); manager.Tell(new CreateConnectionCommand("PLC-A", "OpcUa", new Dictionary(), null, 3)); Assert.Contains("Unknown connection", Subscribe(manager, "PLC-A").ErrorMessage); Assert.DoesNotContain("Unknown connection", Subscribe(manager, "PLC A").ErrorMessage ?? ""); } /// /// Follow-up L2: a Remove immediately followed by a Create of the SAME name (a redeploy that /// rebuilds the connection) lands while the stopped child is still terminating, so its actor /// name is still reserved. The create used to be refused permanently as a "collision" — and /// the deployment manager, having recorded the config hash, never resent it. The manager now /// queues the create until the old child's Terminated arrives. /// [Fact] public void RemoveThenCreateSameName_InsideDisposeWindow_IsCreatedOnceTheOldChildTerminates() { _slowDispose = true; var manager = CreateManager(); manager.Tell(new CreateConnectionCommand("PLC1", "OpcUa", new Dictionary(), null, 3)); manager.Tell(new RemoveConnectionCommand("PLC1")); manager.Tell(new CreateConnectionCommand("PLC1", "OpcUa", new Dictionary(), null, 3)); AwaitAssert( () => Assert.DoesNotContain("Unknown connection", Subscribe(manager, "PLC1").ErrorMessage ?? ""), TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(100)); } /// /// Follow-up L2: a Remove that arrives while a create is still queued behind a terminating /// child cancels the queued create — the connection stays removed. /// [Fact] public void RemoveWhileCreateQueued_CancelsTheQueuedCreate() { _slowDispose = true; var manager = CreateManager(); manager.Tell(new CreateConnectionCommand("PLC2", "OpcUa", new Dictionary(), null, 3)); manager.Tell(new RemoveConnectionCommand("PLC2")); manager.Tell(new CreateConnectionCommand("PLC2", "OpcUa", new Dictionary(), null, 3)); manager.Tell(new RemoveConnectionCommand("PLC2")); // Let the old child terminate (and any wrongly-queued create run) before asserting. Thread.Sleep(1000); Assert.Contains("Unknown connection", Subscribe(manager, "PLC2").ErrorMessage); } }