diff --git a/docs/drivers/OpcUaClient-Test-Fixture.md b/docs/drivers/OpcUaClient-Test-Fixture.md index 23e67e34..c4651a54 100644 --- a/docs/drivers/OpcUaClient-Test-Fixture.md +++ b/docs/drivers/OpcUaClient-Test-Fixture.md @@ -52,6 +52,14 @@ the tests mock. Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` + `IHostConnectivityProbe` (via the Secure Channel exchange). +### In-process wire tests (no opc-plc) + +`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests` drives the real driver +against the in-process OtOpcUa server (`ServerEventRig`, authenticating with the rig's API-key credential): +read (+ missing node), write through the node-write gateway (+ read-only refusal, + failed-write revert), +subscribe/unsubscribe, host-status Running -> not-Running when the server stops, and discovery. These need +no Docker. The HistoryRead wire leg is deferred (needs an `IHistorianDataSource` stub on the server). + ### Unit The surface is broad because `OpcUaClientDriver` is the richest-capability diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs index 23db27b2..bfcd4328 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs @@ -5,9 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// -/// Scaffold tests for 's -/// surface that don't require a live remote server. Live-browse coverage lands in a -/// follow-up PR once the in-process OPC UA server fixture is scaffolded. +/// Unit tests for 's +/// surface that don't require a live remote server. Live-browse coverage lives in +/// OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests. /// [Trait("Category", "Unit")] public sealed class OpcUaClientDiscoveryTests diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs index 6307a393..78cc3d26 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs @@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Scaffold-level tests for that don't require a live -/// remote OPC UA server. PR 67+ adds IReadable/IWritable/ITagDiscovery/ISubscribable -/// tests against a local in-process OPC UA server fixture. +/// remote OPC UA server. The IReadable/IWritable/ITagDiscovery/ISubscribable wire-level +/// tests live in OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests. /// [Trait("Category", "Unit")] public sealed class OpcUaClientDriverScaffoldTests diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs index 01b132a4..1a3eba1d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs @@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Unit tests for the IReadable/IWritable surface that don't need a live remote OPC UA -/// server. Wire-level round-trips against a local in-process server fixture land in a -/// follow-up PR once we have one scaffolded. +/// server (the uninitialised-guard contract). The wire-level read/write round-trips live in +/// OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests, against the in-process server. /// [Trait("Category", "Unit")] public sealed class OpcUaClientReadWriteTests diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs index bfde6c6a..8183a907 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs @@ -5,10 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// -/// Scaffold tests for ISubscribable + IHostConnectivityProbe that don't -/// need a live remote server. Live-session tests (subscribe/unsubscribe round-trip, -/// keep-alive transitions) land in a follow-up PR once the in-process OPC UA server -/// fixture is scaffolded. +/// Unit tests for ISubscribable + IHostConnectivityProbe that don't need a +/// live remote server. The live-session tests (subscribe/unsubscribe round-trip, +/// keep-alive transitions) live in OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests. /// [Trait("Category", "Unit")] public sealed class OpcUaClientSubscribeAndProbeTests diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.cs new file mode 100644 index 00000000..63cc8b0b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.cs @@ -0,0 +1,282 @@ +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Wire-level coverage of the real against the in-process OtOpcUa server +/// (), replacing the "lands in a follow-up PR once we have an in-process server +/// fixture" scaffolds in Driver.OpcUaClient.Tests (Gitea #622). No opc-plc and no Docker: every test +/// here runs a real secure-channel + session + subscription exchange over loopback. The driver authenticates +/// with the rig's API-key credential so writes carry the WriteOperate role. +/// +/// HistoryRead is NOT covered here (deferred): the server-side leg needs an IHistorianDataSource +/// stub plus a historized-variable rig seam, and the driver's passthrough shapes are unit-tested already. +/// +/// +[Trait("Category", "Integration")] +public sealed class OpcUaClientDriverWireTests +{ + private const string SpeedRef = "Sim/dev1/Speed"; + private const string RoRef = "Sim/dev1/Counter"; + private const string MissingRef = "Sim/dev1/Missing"; + private const string SpeedId = ServerEventRig.RawDeviceFolder + "/Speed"; + private const string CounterId = ServerEventRig.RawDeviceFolder + "/Counter"; + + private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway + { + public List<(string NodeId, object? Value)> Writes { get; } = []; + + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) + { + lock (Writes) Writes.Add((nodeId, value)); + return Task.FromResult(new NodeWriteOutcome(succeed, succeed ? null : "device refused")); + } + } + + private static RawTagEntry Tag(string rawPath, string nodeId) => + new(rawPath, JsonSerializer.Serialize(new { nodeId }), WriteIdempotent: true); + + private static OpcUaClientDriver NewDriver(ServerEventRig rig, string id, string? browseRoot = null) => + new(new OpcUaClientDriverOptions + { + EndpointUrl = rig.EndpointUrl, + RawTags = + [ + Tag(SpeedRef, ServerEventRig.RawNsu(SpeedId)), + Tag(RoRef, ServerEventRig.RawNsu(CounterId)), + Tag(MissingRef, ServerEventRig.RawNsu($"{ServerEventRig.RawDeviceFolder}/NoSuchNode")), + ], + SecurityPolicy = OpcUaSecurityPolicy.None, + SecurityMode = OpcUaSecurityMode.None, + AuthType = OpcUaAuthType.Username, + Username = "apikey", + Password = ServerEventRig.KeyPassword, + AutoAcceptCertificates = true, + TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform, + Timeout = TimeSpan.FromSeconds(10), + SessionTimeout = TimeSpan.FromSeconds(30), + KeepAliveInterval = TimeSpan.FromSeconds(1), + BrowseRoot = browseRoot, + }, id); + + private static async Task StartRigAsync(string name, CancellationToken ct) + { + var rig = await ServerEventRig.StartAsync(name, encrypted: false, ct); + rig.EnsureRawVariable("Speed", "Float", writable: true); + rig.EnsureRawVariable("Counter", "Int32", writable: false); + return rig; + } + + private static void Publish(ServerEventRig rig, string id, object value) => + rig.Sink.WriteValue(id, value, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + /// Reads round-trip through the real wire; a node the server lacks does not read Good. + [Fact] + public async Task Read_returns_the_published_value_and_a_bad_status_for_a_missing_node() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-read", ct); + Publish(rig, SpeedId, 42.5f); + + await using var drv = NewDriver(rig, "wire-read"); + await drv.InitializeAsync("{}", ct); + + var snaps = await drv.ReadAsync([SpeedRef, MissingRef], ct); + + snaps.Count.ShouldBe(2); + snaps[0].StatusCode.ShouldBe(0u); + Convert.ToSingle(snaps[0].Value).ShouldBe(42.5f); + snaps[1].StatusCode.ShouldNotBe(0u, "a node the server does not have must not read Good"); + } + + /// A write the device accepts lands on the node and reads back. + [Fact] + public async Task Write_roundtrips_through_the_node_write_gateway_and_reads_back() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-write", ct); + var gateway = new RecordingWriteGateway(succeed: true); + rig.Nm.NodeWriteGateway = gateway; + Publish(rig, SpeedId, 1.0f); + + await using var drv = NewDriver(rig, "wire-write"); + await drv.InitializeAsync("{}", ct); + + var results = await drv.WriteAsync([new WriteRequest(SpeedRef, 7.25f)], ct); + + results.Single().StatusCode.ShouldBe(0u); + lock (gateway.Writes) Convert.ToSingle(gateway.Writes.Single().Value).ShouldBe(7.25f); + var back = await drv.ReadAsync([SpeedRef], ct); + Convert.ToSingle(back[0].Value).ShouldBe(7.25f); + } + + /// A write to a read-only node is refused by the server and never reaches the gateway. + [Fact] + public async Task Write_to_a_read_only_node_is_refused_and_never_reaches_the_gateway() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-write-ro", ct); + var gateway = new RecordingWriteGateway(succeed: true); + rig.Nm.NodeWriteGateway = gateway; + Publish(rig, CounterId, 5); + + await using var drv = NewDriver(rig, "wire-write-ro"); + await drv.InitializeAsync("{}", ct); + + var results = await drv.WriteAsync([new WriteRequest(RoRef, 9)], ct); + + results.Single().StatusCode.ShouldNotBe(0u); + lock (gateway.Writes) gateway.Writes.ShouldBeEmpty(); + // Positive control: the same session CAN write the writable node, so the refusal above is the node's. + (await drv.WriteAsync([new WriteRequest(SpeedRef, 2.0f)], ct)).Single().StatusCode.ShouldBe(0u); + lock (gateway.Writes) gateway.Writes.Count.ShouldBe(1); + } + + /// The node manager's write-outcome self-correction is visible to the driver as a revert. + [Fact] + public async Task A_write_the_device_refuses_is_reverted_and_the_driver_reads_the_original_value() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-write-fail", ct); + rig.Nm.NodeWriteGateway = new RecordingWriteGateway(succeed: false); + Publish(rig, SpeedId, 3.0f); + + await using var drv = NewDriver(rig, "wire-write-fail"); + await drv.InitializeAsync("{}", ct); + + await drv.WriteAsync([new WriteRequest(SpeedRef, 99.0f)], ct); + + var reverted = false; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (DateTime.UtcNow < deadline && !reverted) + { + var snap = (await drv.ReadAsync([SpeedRef], ct))[0]; + reverted = Math.Abs(Convert.ToSingle(snap.Value) - 3.0f) < 0.001f; + if (!reverted) await Task.Delay(50, ct); + } + reverted.ShouldBeTrue("the refused write must revert the node to its pre-write value"); + } + + /// A subscription delivers the initial value and then later server-side changes, and stops after unsubscribe. + [Fact] + public async Task Subscribe_delivers_the_initial_value_and_subsequent_changes_until_unsubscribed() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-sub", ct); + Publish(rig, SpeedId, 10.0f); + + await using var drv = NewDriver(rig, "wire-sub"); + await drv.InitializeAsync("{}", ct); + + var seen = new List(); + drv.OnDataChange += (_, e) => + { + if (e.FullReference == SpeedRef && e.Snapshot.StatusCode == 0u) + lock (seen) seen.Add(Convert.ToSingle(e.Snapshot.Value)); + }; + + var handle = await drv.SubscribeAsync([SpeedRef], TimeSpan.FromMilliseconds(100), ct); + await RecordedEvents.WaitUntilAsync(() => { lock (seen) return seen.Contains(10.0f); }); + + Publish(rig, SpeedId, 20.0f); + await RecordedEvents.WaitUntilAsync(() => { lock (seen) return seen.Contains(20.0f); }); + + await drv.UnsubscribeAsync(handle, ct); + + // Absence needs a positive control: after unsubscribing, publish 30.0 and prove a FRESH subscription + // delivers it exactly once, i.e. the dead first subscription contributed nothing. + Publish(rig, SpeedId, 30.0f); + var control = await drv.SubscribeAsync([SpeedRef], TimeSpan.FromMilliseconds(100), ct); + await RecordedEvents.WaitUntilAsync(() => { lock (seen) return seen.Contains(30.0f); }); + await Task.Delay(500, ct); + await drv.UnsubscribeAsync(control, ct); + lock (seen) seen.Count(v => v == 30.0f).ShouldBe(1, "only the control subscription delivers 30.0"); + } + + /// The host probe reports Running while the server is up and leaves Running when it goes away. + [Fact] + public async Task Host_status_is_Running_then_leaves_Running_when_the_server_stops() + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartRigAsync("drv-probe", ct); + await using var drv = NewDriver(rig, "wire-probe"); + try + { + await drv.InitializeAsync("{}", ct); + drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running); + + var transitioned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + drv.OnHostStatusChanged += (_, e) => + { + if (e.NewState != HostState.Running) transitioned.TrySetResult(e.NewState); + }; + + await rig.DisposeAsync(); + rig = null; + + var state = await transitioned.Task.WaitAsync(TimeSpan.FromSeconds(30), ct); + state.ShouldNotBe(HostState.Running); + drv.GetHostStatuses().Single().State.ShouldNotBe(HostState.Running); + } + finally + { + if (rig is not null) await rig.DisposeAsync(); + } + } + + /// Discovery browses the live server's variables into the builder with their real data types. + [Fact] + public async Task Discovery_browses_the_servers_variables_into_the_builder() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-disco", ct); + Publish(rig, SpeedId, 1.0f); + + await using var drv = NewDriver(rig, "wire-disco", browseRoot: ServerEventRig.RawNsu(ServerEventRig.RawDeviceFolder)); + await drv.InitializeAsync("{}", ct); + + var builder = new RecordingBuilder(); + await drv.DiscoverAsync(builder, ct); + + // The server's browse name for a raw variable is its full RawPath, so key on the stable nsu= reference. + var byRef = builder.Variables.ToDictionary(v => v.Info.FullName); + var speed = byRef[ServerEventRig.RawNsu(SpeedId)].Info; + var counter = byRef[ServerEventRig.RawNsu(CounterId)].Info; + speed.DriverDataType.ShouldBe(DriverDataType.Float32); + counter.DriverDataType.ShouldBe(DriverDataType.Int32); + speed.SecurityClass.ShouldNotBe(counter.SecurityClass, + "the writable variable and the read-only one must classify differently"); + } + + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add((browseName, attributeInfo)); + return new Handle(attributeInfo.FullName); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class Handle(string fullReference) : IVariableHandle + { + public string FullReference { get; } = fullReference; + + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink(); + } + + private sealed class Sink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs index 46a90c35..e0bf4ae1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs @@ -138,6 +138,24 @@ internal sealed class ServerEventRig : IAsyncDisposable ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false); } + /// + /// Adds a raw value variable under (the folder is ensured), for tests that + /// drive a real client against plain values rather than alarm conditions. Additive to the alarm helpers. + /// + public string EnsureRawVariable(string name, string dataType, bool writable) + { + Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); + var id = $"{RawDeviceFolder}/{name}"; + Sink.EnsureVariable(id, RawDeviceFolder, name, dataType, writable, AddressSpaceRealm.Raw); + return id; + } + + /// The server-stable nsu= form of a raw-realm string NodeId. + public static string RawNsu(string id) => $"nsu={V3NodeIds.RawNamespaceUri};s={id}"; + + /// The API-key credential admits (username is always apikey). + public static string KeyPassword => $"otua_{KeyId}_secret"; + public void EnsureEquipment() { Sink.EnsureFolder(EqA, parentNodeId: null, displayName: "A", realm: AddressSpaceRealm.Uns); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj index 544f524c..c2b3266f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj @@ -7,6 +7,12 @@ true + + + $(NoWarn);OTOPCUA0001 + + @@ -31,6 +37,8 @@ + +