142 lines
6.4 KiB
C#
142 lines
6.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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 <c>Context.ActorOf</c> throw on the
|
|
/// manager's own thread, restarting it and dropping every live connection.
|
|
/// </summary>
|
|
[Collection("DataConnectionManagerActor")]
|
|
public class ConnectionActorNameTests : TestKit
|
|
{
|
|
private readonly IDataConnectionFactory _factory = Substitute.For<IDataConnectionFactory>();
|
|
private readonly ISiteHealthCollector _health = Substitute.For<ISiteHealthCollector>();
|
|
private volatile bool _slowDispose;
|
|
private readonly DataConnectionOptions _options = new()
|
|
{
|
|
ReconnectInterval = TimeSpan.FromMilliseconds(100),
|
|
TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200)
|
|
};
|
|
|
|
/// <summary>Creates the test kit with a factory that hands out connected mock adapters.</summary>
|
|
public ConnectionActorNameTests()
|
|
{
|
|
_factory.Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>()).Returns(_ =>
|
|
{
|
|
var adapter = Substitute.For<IDataConnection>();
|
|
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
adapter.Status.Returns(ConnectionHealth.Connected);
|
|
adapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
|
|
.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;
|
|
});
|
|
}
|
|
|
|
/// <summary>Every sanitised name is a legal Akka path element, including non-ASCII and '$' input.</summary>
|
|
[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<SubscribeTagsResponse>(TimeSpan.FromSeconds(10));
|
|
}
|
|
|
|
/// <summary>A non-ASCII connection name is created instead of crashing the manager.</summary>
|
|
[Fact]
|
|
public void NonAsciiConnectionName_IsCreated()
|
|
{
|
|
var manager = CreateManager();
|
|
manager.Tell(new CreateConnectionCommand("Ofen Süd", "OpcUa", new Dictionary<string, string>(), null, 3));
|
|
|
|
var response = Subscribe(manager, "Ofen Süd");
|
|
Assert.DoesNotContain("Unknown connection", response.ErrorMessage ?? "");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Two names that sanitise alike: the second fails alone; the first — and the manager —
|
|
/// keep working.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CollidingSanitisedNames_FailOnlyTheLaterConnection()
|
|
{
|
|
var manager = CreateManager();
|
|
manager.Tell(new CreateConnectionCommand("PLC A", "OpcUa", new Dictionary<string, string>(), null, 3));
|
|
manager.Tell(new CreateConnectionCommand("PLC-A", "OpcUa", new Dictionary<string, string>(), null, 3));
|
|
|
|
Assert.Contains("Unknown connection", Subscribe(manager, "PLC-A").ErrorMessage);
|
|
Assert.DoesNotContain("Unknown connection", Subscribe(manager, "PLC A").ErrorMessage ?? "");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RemoveThenCreateSameName_InsideDisposeWindow_IsCreatedOnceTheOldChildTerminates()
|
|
{
|
|
_slowDispose = true;
|
|
var manager = CreateManager();
|
|
manager.Tell(new CreateConnectionCommand("PLC1", "OpcUa", new Dictionary<string, string>(), null, 3));
|
|
manager.Tell(new RemoveConnectionCommand("PLC1"));
|
|
manager.Tell(new CreateConnectionCommand("PLC1", "OpcUa", new Dictionary<string, string>(), null, 3));
|
|
|
|
AwaitAssert(
|
|
() => Assert.DoesNotContain("Unknown connection", Subscribe(manager, "PLC1").ErrorMessage ?? ""),
|
|
TimeSpan.FromSeconds(10),
|
|
TimeSpan.FromMilliseconds(100));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RemoveWhileCreateQueued_CancelsTheQueuedCreate()
|
|
{
|
|
_slowDispose = true;
|
|
var manager = CreateManager();
|
|
manager.Tell(new CreateConnectionCommand("PLC2", "OpcUa", new Dictionary<string, string>(), null, 3));
|
|
manager.Tell(new RemoveConnectionCommand("PLC2"));
|
|
manager.Tell(new CreateConnectionCommand("PLC2", "OpcUa", new Dictionary<string, string>(), 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);
|
|
}
|
|
}
|