From 5475ab2aa318428df6d08196302b8ee5ba7d1abe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 16:04:25 -0400 Subject: [PATCH] test(opcuaclient.browser): unit + opc-plc live coverage --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../OpcUaClientBrowseSessionTests.cs | 76 +++++++++++++++++++ .../OpcUaClientDriverBrowserTests.cs | 52 +++++++++++++ ...Ua.Driver.OpcUaClient.Browser.Tests.csproj | 33 ++++++++ 4 files changed, 162 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index c113e4ce..72d70a3c 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -99,6 +99,7 @@ + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs new file mode 100644 index 00000000..adadfb1e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs @@ -0,0 +1,76 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests; + +/// +/// Live-server tests against the opc-plc Docker fixture. Bring up with +/// `lmxopcua-fix up opcuaclient` from PowerShell before running. These tests +/// drive the internal OpcUaClientBrowseSession through its public factory +/// so no InternalsVisibleTo is needed. +/// Filter out with --filter "Category!=RequiresOpcPlc" when the fixture +/// is unreachable. +/// +[Trait("Category", "RequiresOpcPlc")] +public sealed class OpcUaClientBrowseSessionTests +{ + private static string Endpoint => + Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT") ?? "opc.tcp://10.100.0.35:50000"; + + // Enum values use their numeric ordinal because the browser uses default + // System.Text.Json with no JsonStringEnumConverter. SecurityPolicy.None, + // SecurityMode.None, OpcUaAuthType.Anonymous are all index 0, so omitting them + // also works — keeping them explicit for documentation value. + private static string ConfigJson => $$""" + { + "EndpointUrl":"{{Endpoint}}", + "SecurityPolicy":0, + "SecurityMode":0, + "AuthType":0, + "SessionTimeout":"00:01:00", + "Timeout":"00:00:10", + "PerEndpointConnectTimeout":"00:00:10" + } + """; + + /// RootAsync should surface at least one node under ObjectsFolder + /// (opc-plc exposes a non-empty top level). + [Fact] + public async Task RootAsync_returns_at_least_one_node() + { + var ct = TestContext.Current.CancellationToken; + var browser = new OpcUaClientDriverBrowser(); + await using var session = await browser.OpenAsync(ConfigJson, ct); + var roots = await session.RootAsync(ct); + roots.Count.ShouldBeGreaterThan(0); + } + + /// ExpandAsync should accept a stable NodeId returned by RootAsync + /// and round-trip through the live namespace table. + [Fact] + public async Task ExpandAsync_round_trips_stable_NodeId() + { + var ct = TestContext.Current.CancellationToken; + var browser = new OpcUaClientDriverBrowser(); + await using var session = await browser.OpenAsync(ConfigJson, ct); + var roots = await session.RootAsync(ct); + var folder = roots.FirstOrDefault(n => n.Kind == BrowseNodeKind.Folder); + folder.ShouldNotBeNull("expected at least one Folder under ObjectsFolder"); + var children = await session.ExpandAsync(folder!.NodeId, ct); + children.ShouldNotBeNull(); + } + + /// The OPC UA picker treats variables as terminal leaves, so + /// AttributesAsync must always be empty for this driver. + [Fact] + public async Task AttributesAsync_is_empty_for_opcuaclient() + { + var ct = TestContext.Current.CancellationToken; + var browser = new OpcUaClientDriverBrowser(); + await using var session = await browser.OpenAsync(ConfigJson, ct); + var attrs = await session.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct); + attrs.ShouldBeEmpty(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs new file mode 100644 index 00000000..c61cbd47 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs @@ -0,0 +1,52 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests; + +/// +/// Unit-only coverage of 's pre-connect +/// validation. These tests do not require a live OPC UA endpoint and are safe to +/// run without the opc-plc Docker fixture. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientDriverBrowserTests +{ + private readonly OpcUaClientDriverBrowser _sut = new(); + + /// The DriverType key must match the AdminUI's persisted value. + [Fact] + public void DriverType_is_OpcUaClient() => _sut.DriverType.ShouldBe("OpcUaClient"); + + /// An empty endpoint must fail fast with a clear EndpointUrl-mentioning message. + [Fact] + public async Task OpenAsync_with_empty_endpoint_throws() + { + var json = """{"EndpointUrl":"","EndpointUrls":[]}"""; + var ex = await Should.ThrowAsync( + () => _sut.OpenAsync(json, TestContext.Current.CancellationToken)); + ex.Message.ShouldContain("EndpointUrl"); + } + + /// A JSON literal that deserializes to null must fail fast. + [Fact] + public async Task OpenAsync_with_null_json_throws() + { + var ex = await Should.ThrowAsync( + () => _sut.OpenAsync("null", TestContext.Current.CancellationToken)); + ex.Message.ShouldContain("null"); + } + + /// Certificate auth is not supported by the browser; the failure message + /// must say so explicitly rather than surfacing a downstream COM/SDK error. + /// OpcUaAuthType.Certificate serializes as the numeric value 2 under the + /// browser's default System.Text.Json options (no string-enum converter). + [Fact] + public async Task OpenAsync_with_certificate_auth_throws_clear_message() + { + var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":2}"""; + var ex = await Should.ThrowAsync( + () => _sut.OpenAsync(json, TestContext.Current.CancellationToken)); + ex.Message.ShouldContain("Certificate"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj new file mode 100644 index 00000000..8caa7d5b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + +