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:
Joseph Doherty
2026-05-17 03:18:24 -04:00
parent 3d3f43229f
commit 14ba5495d1
7 changed files with 408 additions and 66 deletions

View File

@@ -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()
{