Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientFailoverTests.cs
Joseph Doherty 0f509fbd3a Auto: opcuaclient-6 — Discovery URL FindServers
Adds optional `DiscoveryUrl` knob to OpcUaClientDriverOptions. When set,
the driver runs `DiscoveryClient.CreateAsync` + `FindServersAsync` +
`GetEndpointsAsync` against that URL during InitializeAsync and prepends
the discovered endpoint URLs (filtered to matching SecurityPolicy +
SecurityMode) to the failover candidate list. De-duplicates URLs that
appear in both discovered and static lists (case-insensitive). Discovery
failures are non-fatal — falls back to statically configured candidates.

The doc comment notes that FindServers requires SecurityMode=None on the
discovery channel per OPC UA spec, even when the data channel uses Sign
or SignAndEncrypt.

Closes #278

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:10:59 -04:00

161 lines
6.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 void DiscoveryUrl_defaults_null_so_existing_configs_are_unaffected()
{
var opts = new OpcUaClientDriverOptions();
opts.DiscoveryUrl.ShouldBeNull();
}
[Fact]
public void ResolveEndpointCandidates_prepends_discovered_urls_before_static_candidates()
{
var opts = new OpcUaClientDriverOptions
{
EndpointUrls = ["opc.tcp://static1:4840", "opc.tcp://static2:4841"],
};
var discovered = new[] { "opc.tcp://discovered1:4840", "opc.tcp://discovered2:4841" };
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
list.Count.ShouldBe(4);
list[0].ShouldBe("opc.tcp://discovered1:4840");
list[1].ShouldBe("opc.tcp://discovered2:4841");
list[2].ShouldBe("opc.tcp://static1:4840");
list[3].ShouldBe("opc.tcp://static2:4841");
}
[Fact]
public void ResolveEndpointCandidates_dedupes_url_appearing_in_both_discovered_and_static()
{
var opts = new OpcUaClientDriverOptions
{
EndpointUrls = ["opc.tcp://shared:4840", "opc.tcp://static:4841"],
};
var discovered = new[] { "opc.tcp://shared:4840", "opc.tcp://only-discovered:4842" };
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
list.Count.ShouldBe(3);
list[0].ShouldBe("opc.tcp://shared:4840");
list[1].ShouldBe("opc.tcp://only-discovered:4842");
list[2].ShouldBe("opc.tcp://static:4841");
}
[Fact]
public void ResolveEndpointCandidates_dedup_is_case_insensitive()
{
// Discovery URLs sometimes return uppercase hostnames; static config typically has
// lowercase. The de-dup should treat them as the same URL so the failover sweep
// doesn't attempt the same host twice in a row.
var opts = new OpcUaClientDriverOptions
{
EndpointUrls = ["opc.tcp://host:4840"],
};
var discovered = new[] { "OPC.TCP://HOST:4840" };
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
list.Count.ShouldBe(1);
}
[Fact]
public void ResolveEndpointCandidates_with_only_default_endpoint_is_replaced_by_discovery()
{
// No EndpointUrls list, default EndpointUrl — the static "candidate" is the default
// localhost shortcut. When discovery returns URLs they should still be prepended
// (the localhost default isn't worth filtering out specially since it's harmless to
// try last and it's still a valid configured fallback).
var opts = new OpcUaClientDriverOptions(); // EndpointUrl=opc.tcp://localhost:4840 default
var discovered = new[] { "opc.tcp://discovered:4840" };
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
list[0].ShouldBe("opc.tcp://discovered:4840");
list.ShouldContain("opc.tcp://localhost:4840");
}
[Fact]
public void ResolveEndpointCandidates_no_discovered_falls_back_to_static_behaviour()
{
var opts = new OpcUaClientDriverOptions
{
EndpointUrls = ["opc.tcp://only:4840"],
};
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, []);
list.Count.ShouldBe(1);
list[0].ShouldBe("opc.tcp://only:4840");
}
[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,
};
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);
}
}