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(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); } }