Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientFailoverTests.cs
Joseph Doherty ebc0511c72 fix(driver-opcuaclient): resolve High code-review findings (Driver.OpcUaClient-001..-005)
Driver.OpcUaClient-001 — ReadAsync/WriteAsync/DiscoverAsync captured the
session before acquiring _gate, so a reconnect that completed while the
operation was blocked on the gate left the wire call bound to a stale,
closed session. All three now re-read Session (and parse NodeIds) inside
the _gate critical section after WaitAsync returns.

Driver.OpcUaClient-002 — OnReconnectComplete ignored the give-up (null
session) case, permanently wedging the driver with no Faulted signal and
no reconnect loop. The give-up branch now transitions HostState to
Faulted, sets a Faulted DriverHealth with an explanatory message, and
re-arms a fresh SessionReconnectHandler (TryRearmReconnect) against the
last-known session so an always-on gateway self-heals.

Driver.OpcUaClient-003 — BrowseRecursiveAsync discarded browse
continuation points, silently truncating large remote folders.
It now loops on BrowseResult.ContinuationPoint calling BrowseNextAsync
and appending each page until the continuation point is empty.

Driver.OpcUaClient-004 — driver-specs.md §8 namespace handling was
absent. Added NamespaceMap (built from session.NamespaceUris at connect,
rebuilt on reconnect) which persists discovered NodeIds in the
server-stable nsu=<uri>;... form; reads/writes re-resolve that form
against the current session so a remote namespace-table reorder no
longer misaddresses nodes. Added the TargetNamespaceKind option +
UnsMappingTable and ValidateNamespaceKind startup enforcement.

Driver.OpcUaClient-005 — OnKeepAlive read/wrote _reconnectHandler
without a lock, racing the SDK keep-alive timer thread and leaking
handlers. The check-and-set in OnKeepAlive, the take-and-clear in
ShutdownAsync, and the dispose/re-arm in OnReconnectComplete now all
run inside the _probeLock critical section.

Adds OpcUaClientNamespaceTests (11 xUnit + Shouldly regression tests)
covering ValidateNamespaceKind and the NamespaceMap stable encoding.
Reconnect/browse wire paths remain fixture-gated per finding -015.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:41:28 -04:00

85 lines
3.4 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientFailoverTests
{
[Fact]
public void ResolveEndpointCandidates_prefers_EndpointUrls_when_provided()
{
var opts = new OpcUaClientDriverOptions
{
EndpointUrl = "opc.tcp://fallback:4840",
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
};
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
list.Count.ShouldBe(2);
list[0].ShouldBe("opc.tcp://primary:4840");
list[1].ShouldBe("opc.tcp://backup:4841");
}
[Fact]
public void ResolveEndpointCandidates_falls_back_to_single_EndpointUrl_when_list_empty()
{
var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:4840" };
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
list.Count.ShouldBe(1);
list[0].ShouldBe("opc.tcp://only:4840");
}
[Fact]
public void ResolveEndpointCandidates_empty_list_treated_as_fallback_to_EndpointUrl()
{
// Explicit empty list should still fall back to the single-URL shortcut rather than
// producing a zero-candidate sweep that would immediately throw with no URLs tried.
var opts = new OpcUaClientDriverOptions
{
EndpointUrl = "opc.tcp://single:4840",
EndpointUrls = [],
};
OpcUaClientDriver.ResolveEndpointCandidates(opts).Count.ShouldBe(1);
}
[Fact]
public void HostName_uses_first_candidate_before_connect()
{
var opts = new OpcUaClientDriverOptions
{
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
};
using var drv = new OpcUaClientDriver(opts, "opcua-host");
drv.HostName.ShouldBe("opc.tcp://primary:4840",
"pre-connect the dashboard should show the first candidate URL so operators can link back");
}
[Fact]
public async Task Initialize_against_all_unreachable_endpoints_throws_AggregateException_listing_each()
{
// Port 1 + port 2 + port 3 on loopback are all guaranteed closed (TCP RST immediate).
// Failover sweep should attempt all three and throw AggregateException naming each URL
// so operators see exactly which candidates were tried.
var opts = new OpcUaClientDriverOptions
{
EndpointUrls = ["opc.tcp://127.0.0.1:1", "opc.tcp://127.0.0.1:2", "opc.tcp://127.0.0.1:3"],
PerEndpointConnectTimeout = TimeSpan.FromMilliseconds(500),
Timeout = TimeSpan.FromMilliseconds(500),
AutoAcceptCertificates = true,
// SystemPlatform kind needs no UNS mapping table — keeps this failover test
// focused on the endpoint sweep rather than §8 namespace validation.
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
};
using var drv = new OpcUaClientDriver(opts, "opcua-failover");
var ex = await Should.ThrowAsync<AggregateException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("127.0.0.1:1");
ex.Message.ShouldContain("127.0.0.1:2");
ex.Message.ShouldContain("127.0.0.1:3");
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
}