fix(data-connection-layer): resolve DataConnectionLayer-014..017 — real logger for OPC UA client, initial-connect failover, accurate subscribe response, per-tag write-batch results
This commit is contained in:
@@ -803,6 +803,132 @@ public class DataConnectionActorTests : TestKit
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
// ── DataConnectionLayer-015: initial-connect failures must trigger failover ──
|
||||
|
||||
[Fact]
|
||||
public async Task DCL015_PrimaryDownAtStartup_FailsOverToBackup()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-015. HandleConnectResult — the handler
|
||||
// for the INITIAL connection attempt in the Connecting state — only logged and
|
||||
// re-armed the reconnect timer. It never incremented _consecutiveFailures and
|
||||
// never switched endpoint, so a primary that is unreachable when the actor first
|
||||
// starts (a fresh deployment, a site restart, a primary simply down) retried the
|
||||
// primary forever and never tried the configured backup. After the fix the
|
||||
// initial connect participates in the failover counter like HandleReconnectResult.
|
||||
var primaryConfig = new Dictionary<string, string> { ["Endpoint"] = "opc.tcp://primary:4840" };
|
||||
var backupConfig = new Dictionary<string, string> { ["Endpoint"] = "opc.tcp://backup:4840" };
|
||||
var primaryAdapter = Substitute.For<IDataConnection>();
|
||||
var backupAdapter = Substitute.For<IDataConnection>();
|
||||
|
||||
// Primary is down from the very first attempt — it never connects.
|
||||
primaryAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException(new Exception("Connection refused")));
|
||||
|
||||
// Factory returns the backup adapter when called with the backup config.
|
||||
_mockFactory.Create("OpcUa", Arg.Is<IDictionary<string, string>>(d => d["Endpoint"] == "opc.tcp://backup:4840"))
|
||||
.Returns(backupAdapter);
|
||||
backupAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var actor = CreateFailoverActor(primaryAdapter, "dcl015-startup-failover",
|
||||
primaryConfig, backupConfig, failoverRetryCount: 2);
|
||||
|
||||
// After failoverRetryCount initial-connect failures on the primary, the actor
|
||||
// must build the backup adapter. Pre-fix the factory was never called.
|
||||
AwaitCondition(() =>
|
||||
_mockFactory.ReceivedCalls().Any(c =>
|
||||
c.GetMethodInfo().Name == "Create" &&
|
||||
c.GetArguments()[1] is IDictionary<string, string> d &&
|
||||
d["Endpoint"] == "opc.tcp://backup:4840"),
|
||||
TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL015_SingleEndpointDownAtStartup_RetriesIndefinitely_NoFailover()
|
||||
{
|
||||
// Companion guard: a single-endpoint connection (no backup) whose primary is
|
||||
// unreachable at startup must keep retrying the same endpoint indefinitely — the
|
||||
// initial-connect failover counter must not synthesise a non-existent backup.
|
||||
var primaryConfig = new Dictionary<string, string> { ["Endpoint"] = "opc.tcp://primary:4840" };
|
||||
var primaryAdapter = Substitute.For<IDataConnection>();
|
||||
var connectCount = 0;
|
||||
primaryAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ =>
|
||||
{
|
||||
Interlocked.Increment(ref connectCount);
|
||||
return Task.FromException(new Exception("Connection refused"));
|
||||
});
|
||||
|
||||
var actor = CreateFailoverActor(primaryAdapter, "dcl015-no-backup",
|
||||
primaryConfig, backupConfig: null, failoverRetryCount: 2);
|
||||
|
||||
// Many retries occur (well past the failover threshold) but no adapter is ever
|
||||
// created via the factory — there is nothing to fail over to.
|
||||
AwaitCondition(() => connectCount >= 6, TimeSpan.FromSeconds(10));
|
||||
_mockFactory.DidNotReceive().Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>());
|
||||
}
|
||||
|
||||
// ── DataConnectionLayer-016: subscribe response must reflect a connection-level failure ──
|
||||
|
||||
[Fact]
|
||||
public async Task DCL016_ConnectionLevelSubscribeFailure_RepliesWithUnsuccessfulResponse()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-016. When a subscribe arrives while the
|
||||
// adapter is silently down, HandleSubscribeCompleted drove the actor into
|
||||
// Reconnecting (a connection-level failure) but still replied to the caller with
|
||||
// SubscribeTagsResponse(Success: true, Error: null). The Instance Actor was told
|
||||
// the subscribe succeeded while the tags were never actually subscribed at the
|
||||
// adapter. After the fix the response matches the actor's own assessment:
|
||||
// Success: false with an explanatory error.
|
||||
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
_mockAdapter.Status.Returns(ConnectionHealth.Connected);
|
||||
// Subscribe fails at connection level (InvalidOperationException from EnsureConnected).
|
||||
_mockAdapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<string>(
|
||||
new InvalidOperationException("OPC UA client is not connected.")));
|
||||
_mockAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ReadResult(false, null, null));
|
||||
|
||||
var actor = CreateConnectionActor("dcl016-conn-fail");
|
||||
await Task.Delay(300);
|
||||
|
||||
actor.Tell(new SubscribeTagsRequest(
|
||||
"c1", "inst1", "dcl016-conn-fail", ["some/tag"], DateTimeOffset.UtcNow));
|
||||
|
||||
// The response must NOT claim success — the connection-level failure that drove
|
||||
// Reconnecting means the tags were never subscribed at the adapter.
|
||||
var response = ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.False(response.Success);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL016_GenuineResolutionFailure_StillRepliesSuccess()
|
||||
{
|
||||
// Companion guard: a genuine tag-resolution failure (the node does not exist) is
|
||||
// a runtime quality concern, not a connection-level fault — the design tracks it
|
||||
// via _unresolvedTags and a Bad-quality TagValueUpdate. The overall subscribe
|
||||
// response stays Success: true so this case is not regressed by the 016 fix.
|
||||
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
_mockAdapter.Status.Returns(ConnectionHealth.Connected);
|
||||
_mockAdapter.SubscribeAsync("missing/tag", Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<string>(new KeyNotFoundException("node not found")));
|
||||
_mockAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ReadResult(false, null, null));
|
||||
|
||||
var actor = CreateConnectionActor("dcl016-genuine");
|
||||
await Task.Delay(300);
|
||||
|
||||
actor.Tell(new SubscribeTagsRequest(
|
||||
"c1", "inst1", "dcl016-genuine", ["missing/tag"], DateTimeOffset.UtcNow));
|
||||
|
||||
var ack = FishForMessage<SubscribeTagsResponse>(_ => true, TimeSpan.FromSeconds(5));
|
||||
Assert.True(ack.Success);
|
||||
Assert.Null(ack.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL001_SubscribeWithFailedTags_CountsResolvedAndUnresolvedSeparately()
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ScadaLink.Commons.Interfaces.Protocol;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.DataConnectionLayer;
|
||||
using ScadaLink.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ScadaLink.DataConnectionLayer.Tests;
|
||||
@@ -54,6 +56,63 @@ public class OpcUaCertificateDefaultTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataConnectionLayer-014: the DCL-012 auto-accept-certificate security warning is
|
||||
/// only effective if RealOpcUaClient is built with a real logger. The only production
|
||||
/// path that constructs one is RealOpcUaClientFactory.Create(); that factory must
|
||||
/// thread a logger through, otherwise the warning sinks into NullLogger and an
|
||||
/// operator who enables AutoAcceptUntrustedCerts sees no signal anywhere.
|
||||
/// </summary>
|
||||
public class RealOpcUaClientFactoryLoggerTests
|
||||
{
|
||||
private static ILogger<RealOpcUaClient> ReadLogger(RealOpcUaClient client)
|
||||
{
|
||||
var field = typeof(RealOpcUaClient).GetField("_logger",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.NotNull(field);
|
||||
return (ILogger<RealOpcUaClient>)field!.GetValue(client)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DCL014_RealOpcUaClientFactory_CreatesClientWithRealLogger()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-014. RealOpcUaClientFactory.Create()
|
||||
// constructed `new RealOpcUaClient(_globalOptions)` with no logger, so the
|
||||
// DCL-012 man-in-the-middle warning was always discarded by NullLogger in
|
||||
// production. After the fix the factory accepts an ILoggerFactory and passes a
|
||||
// real ILogger<RealOpcUaClient> into every client it creates.
|
||||
using var loggerFactory = LoggerFactory.Create(b => { });
|
||||
var factory = new RealOpcUaClientFactory(new OpcUaGlobalOptions(), loggerFactory);
|
||||
|
||||
var client = factory.Create();
|
||||
|
||||
var logger = ReadLogger((RealOpcUaClient)client);
|
||||
Assert.NotSame(NullLogger<RealOpcUaClient>.Instance, logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DCL014_DataConnectionFactory_ThreadsLoggerToRealOpcUaClient()
|
||||
{
|
||||
// The full production wiring: DataConnectionFactory holds an ILoggerFactory and
|
||||
// registers the OpcUa adapter. The RealOpcUaClient it ultimately builds must end
|
||||
// up with a real (non-Null) logger so the auto-accept-cert warning is visible.
|
||||
using var loggerFactory = LoggerFactory.Create(b => { });
|
||||
var dataConnectionFactory = new DataConnectionFactory(loggerFactory);
|
||||
var adapter = (OpcUaDataConnection)dataConnectionFactory.Create(
|
||||
"OpcUa", new Dictionary<string, string>());
|
||||
|
||||
// Reach the RealOpcUaClient the adapter would create on connect.
|
||||
var clientFactoryField = typeof(OpcUaDataConnection).GetField("_clientFactory",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.NotNull(clientFactoryField);
|
||||
var clientFactory = (RealOpcUaClientFactory)clientFactoryField!.GetValue(adapter)!;
|
||||
var client = (RealOpcUaClient)clientFactory.Create();
|
||||
|
||||
var logger = ReadLogger(client);
|
||||
Assert.NotSame(NullLogger<RealOpcUaClient>.Instance, logger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataConnectionLayer-009: failover-stability tunables must be configurable.
|
||||
/// </summary>
|
||||
@@ -269,6 +328,64 @@ public class OpcUaDataConnectionTests
|
||||
Assert.NotNull(results["bad"].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL017_WriteBatch_ReturnsPerTagResults_WhenConnectionDropsMidBatch()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-017. WriteBatchAsync looped calling
|
||||
// WriteAsync per tag; WriteAsync first calls EnsureConnected(), which throws
|
||||
// InvalidOperationException when the client is disconnected. WriteBatchAsync did
|
||||
// not catch that, so a connection dropping partway through a batch made the whole
|
||||
// WriteBatchAsync throw — the caller lost the per-tag outcomes for the tags that
|
||||
// already wrote. After the fix (mirroring DCL-007's ReadBatchAsync) each per-tag
|
||||
// failure is recorded as a failed WriteResult and the batch returns a complete map.
|
||||
var writeCount = 0;
|
||||
// First write succeeds; then the client "disconnects" so EnsureConnected throws.
|
||||
_mockClient.IsConnected.Returns(_ => Interlocked.Increment(ref writeCount) <= 1);
|
||||
_mockClient.WriteValueAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns((uint)0);
|
||||
|
||||
// Connect leaves IsConnected true for the first WriteAsync's EnsureConnected check.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
// Re-arm: IsConnected true for tag1's check, false for tag2 and tag3.
|
||||
var checks = 0;
|
||||
_mockClient.IsConnected.Returns(_ => Interlocked.Increment(ref checks) <= 1);
|
||||
|
||||
var results = await _adapter.WriteBatchAsync(new Dictionary<string, object?>
|
||||
{
|
||||
["tag1"] = 1,
|
||||
["tag2"] = 2,
|
||||
["tag3"] = 3
|
||||
});
|
||||
|
||||
// Every requested tag is present in the result map — the batch was not aborted.
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.True(results["tag1"].Success);
|
||||
// tag2 and tag3 fail at the connection check but are reported per-tag.
|
||||
Assert.False(results["tag2"].Success);
|
||||
Assert.NotNull(results["tag2"].ErrorMessage);
|
||||
Assert.False(results["tag3"].Success);
|
||||
Assert.NotNull(results["tag3"].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL017_WriteBatch_CancellationAbortsWholeBatch()
|
||||
{
|
||||
// Companion guard: a cancelled batch must still abort as a whole — only
|
||||
// connection/device faults are demoted to per-tag results, never cancellation.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
_mockClient.WriteValueAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns<uint>(_ => throw new OperationCanceledException());
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
_adapter.WriteBatchAsync(new Dictionary<string, object?> { ["tag1"] = 1 }, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotConnected_ThrowsOnOperations()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user