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.Messages.Management; 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; /// /// Task 10 (opcua-tag-browser): the site-side /// + child /// together resolve /// against the live adapter and surface /// every browse outcome as a typed . The split is: /// the manager owns (only it /// knows the per-site connection set); everything else lives in the child where /// the adapter is held — from the /// capability check, / /// / /// from the adapter call. These tests guard that split. /// public class DataConnectionManagerBrowseHandlerTests : TestKit { private readonly IDataConnectionFactory _factory; private readonly ISiteHealthCollector _healthCollector; private readonly DataConnectionOptions _options; public DataConnectionManagerBrowseHandlerTests() : base(@"akka.loglevel = WARNING") { _factory = Substitute.For(); _healthCollector = Substitute.For(); _options = new DataConnectionOptions { ReconnectInterval = TimeSpan.FromSeconds(30), TagResolutionRetryInterval = TimeSpan.FromSeconds(30), }; } [Fact] public void Unknown_connection_name_returns_ConnectionNotFound() { var manager = Sys.ActorOf(Props.Create(() => new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); // No CreateConnectionCommand sent — the manager has zero children, so a // browse against any name must be rejected with ConnectionNotFound // (the manager is the only actor with site-level visibility). manager.Tell(new BrowseNodeCommand("unknown-connection", ParentNodeId: null)); var reply = ExpectMsg(); Assert.NotNull(reply.Failure); Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind); Assert.Empty(reply.Children); } [Fact] public void Non_browsable_adapter_returns_NotBrowsable() { // Bare IDataConnection — no IBrowsableDataConnection. The child actor's // capability check must surface this as NotBrowsable. var adapter = Substitute.For(); adapter.ConnectAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); adapter.Status.Returns(ConnectionHealth.Connected); _factory.Create("OpcUa", Arg.Any>()).Returns(adapter); var manager = Sys.ActorOf(Props.Create(() => new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); manager.Tell(new CreateConnectionCommand( "conn-bare", "OpcUa", new Dictionary(), null, 3)); // Give the manager a moment to spawn the child actor. We do not need to // wait for Connected — the browse handler runs in all states. AwaitCondition( () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), TimeSpan.FromSeconds(2)); manager.Tell(new BrowseNodeCommand("conn-bare", ParentNodeId: null)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.NotNull(reply.Failure); Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind); Assert.Empty(reply.Children); } [Fact] public void Success_path_returns_mapped_children() { // Adapter implementing both IDataConnection (so DataConnectionActor can // run its lifecycle) AND IBrowsableDataConnection (so the browse handler // takes the success path). var adapter = Substitute.For(); ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); var children = new[] { new BrowseNode("ns=2;s=A", "A", BrowseNodeClass.Variable, HasChildren: false), new BrowseNode("ns=2;s=B", "B", BrowseNodeClass.Object, HasChildren: true), }; ((IBrowsableDataConnection)adapter) .BrowseChildrenAsync(null, Arg.Any()) .Returns(new BrowseChildrenResult(children, Truncated: false)); _factory.Create("OpcUa", Arg.Any>()) .Returns((IDataConnection)adapter); var manager = Sys.ActorOf(Props.Create(() => new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); manager.Tell(new CreateConnectionCommand( "conn-ok", "OpcUa", new Dictionary(), null, 3)); AwaitCondition( () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), TimeSpan.FromSeconds(2)); manager.Tell(new BrowseNodeCommand("conn-ok", ParentNodeId: null)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.Null(reply.Failure); Assert.Equal(2, reply.Children.Count); Assert.Equal("ns=2;s=A", reply.Children[0].NodeId); Assert.Equal("ns=2;s=B", reply.Children[1].NodeId); Assert.False(reply.Truncated); } [Fact] public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected() { var adapter = Substitute.For(); ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); ((IBrowsableDataConnection)adapter) .BrowseChildrenAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromException( new ConnectionNotConnectedException("OPC UA session is not connected."))); _factory.Create("OpcUa", Arg.Any>()) .Returns((IDataConnection)adapter); var manager = Sys.ActorOf(Props.Create(() => new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); manager.Tell(new CreateConnectionCommand( "conn-down", "OpcUa", new Dictionary(), null, 3)); AwaitCondition( () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), TimeSpan.FromSeconds(2)); manager.Tell(new BrowseNodeCommand("conn-down", ParentNodeId: null)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.NotNull(reply.Failure); Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind); Assert.Empty(reply.Children); } [Fact] public void NotSupportedException_maps_to_NotBrowsable_carrying_message() { // A browsable adapter that is connected but whose server/protocol cannot // browse (e.g. an MxGateway build predating the BrowseChildren RPC, which // answers gRPC Unimplemented) throws NotSupportedException. The actor must // surface this as NotBrowsable with the adapter's actionable message // carried verbatim — distinct from the capability-check NotBrowsable // (non-browsable adapter), which has no message. const string reason = "The connected MxGateway build does not support hierarchy browsing. " + "Update the gateway to a build that implements BrowseChildren, " + "or enter the tag reference manually."; var adapter = Substitute.For(); ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); ((IBrowsableDataConnection)adapter) .BrowseChildrenAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromException(new NotSupportedException(reason))); _factory.Create("MxGateway", Arg.Any>()) .Returns((IDataConnection)adapter); var manager = Sys.ActorOf(Props.Create(() => new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); manager.Tell(new CreateConnectionCommand( "conn-old-gw", "MxGateway", new Dictionary(), null, 3)); AwaitCondition( () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), TimeSpan.FromSeconds(2)); manager.Tell(new BrowseNodeCommand("conn-old-gw", ParentNodeId: null)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.NotNull(reply.Failure); Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind); Assert.Equal(reason, reply.Failure.Message); Assert.Empty(reply.Children); } [Fact] public void Oversized_child_list_is_capped_to_frame_budget_and_marked_truncated() { // A level large enough to exceed Akka's 128 KB remote frame must be clipped // by the actor BEFORE it crosses the site→central boundary — otherwise the // reply is silently discarded and the picker hangs. This guards every // protocol's reply, regardless of how the adapter paginates upstream. The // adapter here reports Truncated=false; the actor must still truncate purely // on serialized size. const int byteBudget = 100 * 1024; // mirrors DataConnectionActor.BrowseResultByteBudget var bigList = Enumerable.Range(0, 3000) .Select(i => new BrowseNode($"ns=2;s=Item{i:D5}", $"Item{i:D5}", BrowseNodeClass.Variable, HasChildren: false)) .ToArray(); var adapter = Substitute.For(); ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); ((IBrowsableDataConnection)adapter) .BrowseChildrenAsync(null, Arg.Any()) .Returns(new BrowseChildrenResult(bigList, Truncated: false)); _factory.Create("OpcUa", Arg.Any>()) .Returns((IDataConnection)adapter); var manager = Sys.ActorOf(Props.Create(() => new DataConnectionManagerActor(_factory, _options, _healthCollector, null))); manager.Tell(new CreateConnectionCommand( "conn-big", "OpcUa", new Dictionary(), null, 3)); AwaitCondition( () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), TimeSpan.FromSeconds(2)); manager.Tell(new BrowseNodeCommand("conn-big", ParentNodeId: null)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.Null(reply.Failure); Assert.True(reply.Truncated, "an oversized level must be reported as truncated"); Assert.True(reply.Children.Count > 0, "the cap must still return a usable prefix"); Assert.True(reply.Children.Count < bigList.Length, "the level must actually be clipped"); // The kept prefix's estimated serialized size must respect the budget. var keptBytes = reply.Children.Sum(n => 64 + n.NodeId.Length + n.DisplayName.Length); Assert.True(keptBytes <= byteBudget, $"kept estimate {keptBytes} exceeds budget {byteBudget}"); } }