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 B5 (T15): the site-side + child /// together resolve a /// against the live adapter and surface /// every search outcome as a typed (the search path /// reuses the browse failure kinds). The split mirrors browse exactly: the /// manager owns (only it knows /// the per-site connection set); the child owns the capability check /// ( when the adapter is not /// ) and the adapter-call failures /// ( / /// / ). /// public class DataConnectionManagerSearchHandlerTests : TestKit { private readonly IDataConnectionFactory _factory; private readonly ISiteHealthCollector _healthCollector; private readonly DataConnectionOptions _options; public DataConnectionManagerSearchHandlerTests() : 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 // search against any name must be rejected with ConnectionNotFound (the // manager is the only actor with site-level visibility). manager.Tell(new SearchAddressSpaceCommand("unknown-connection", "Tag", 5, 100)); var reply = ExpectMsg(); Assert.NotNull(reply.Failure); Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind); Assert.Empty(reply.Matches); Assert.False(reply.CapReached); } [Fact] public void Non_searchable_adapter_returns_NotBrowsable() { // Bare IDataConnection — no IAddressSpaceSearchable. 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)); AwaitCondition( () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), TimeSpan.FromSeconds(2)); manager.Tell(new SearchAddressSpaceCommand("conn-bare", "Tag", 5, 100)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.NotNull(reply.Failure); Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind); Assert.Empty(reply.Matches); } [Fact] public void Success_path_returns_mapped_matches() { // Adapter implementing both IDataConnection (so DataConnectionActor can run // its lifecycle) AND IAddressSpaceSearchable (so the search 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 matches = new[] { new AddressSpaceMatch( new BrowseNode("ns=2;s=A", "TagA", BrowseNodeClass.Variable, HasChildren: false), "/Folder1/TagA"), new AddressSpaceMatch( new BrowseNode("ns=2;s=B", "TagB", BrowseNodeClass.Variable, HasChildren: false), "/Folder2/TagB"), }; ((IAddressSpaceSearchable)adapter) .SearchAddressSpaceAsync("Tag", Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new AddressSpaceSearchResult(matches, CapReached: true)); _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 SearchAddressSpaceCommand("conn-ok", "Tag", 5, 100)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.Null(reply.Failure); Assert.Equal(2, reply.Matches.Count); Assert.Equal("ns=2;s=A", reply.Matches[0].Node.NodeId); Assert.Equal("/Folder1/TagA", reply.Matches[0].Path); Assert.Equal("ns=2;s=B", reply.Matches[1].Node.NodeId); Assert.True(reply.CapReached); } [Fact] public void Query_depth_and_results_thread_through_to_the_adapter() { // The command's Query/MaxDepth/MaxResults must reach the adapter verbatim. string? receivedQuery = "NOT-CALLED"; int receivedDepth = -1; int receivedResults = -1; var adapter = Substitute.For(); ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); ((IAddressSpaceSearchable)adapter) .SearchAddressSpaceAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ci => { receivedQuery = ci.ArgAt(0); receivedDepth = ci.ArgAt(1); receivedResults = ci.ArgAt(2); return new AddressSpaceSearchResult(Array.Empty(), CapReached: 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-args", "OpcUa", new Dictionary(), null, 3)); AwaitCondition( () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), TimeSpan.FromSeconds(2)); manager.Tell(new SearchAddressSpaceCommand("conn-args", "Boiler", 7, 250)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.Null(reply.Failure); Assert.Equal("Boiler", receivedQuery); Assert.Equal(7, receivedDepth); Assert.Equal(250, receivedResults); Assert.Empty(reply.Matches); } [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); ((IAddressSpaceSearchable)adapter) .SearchAddressSpaceAsync(Arg.Any(), Arg.Any(), 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 SearchAddressSpaceCommand("conn-down", "Tag", 5, 100)); var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.NotNull(reply.Failure); Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind); Assert.Empty(reply.Matches); } }