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);
}
}