using System.Text.Json; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Unit tests for upstream-redundancy failover (PR-14, issue #286). The driver /// exposes two test seams — /// and — that bypass /// the SDK's session-create + TransferSubscriptions machinery so we can assert the /// decision logic without standing up two real OPC UA sessions. /// [Trait("Category", "Unit")] public sealed class OpcUaClientRedundancyTests { [Fact] public void Redundancy_options_default_to_disabled() { var opts = new OpcUaClientDriverOptions(); opts.Redundancy.ShouldNotBeNull(); opts.Redundancy.Enabled.ShouldBeFalse( "default deployments do client-side failover via EndpointUrls; upstream redundancy is opt-in"); opts.Redundancy.ServiceLevelThreshold.ShouldBe((ushort)200, "OPC UA spec convention: 200+ = healthy, lower = degraded"); opts.Redundancy.ResolvedRecheckInterval.ShouldBe(TimeSpan.FromSeconds(5)); } [Fact] public void DTO_json_round_trip_preserves_redundancy_settings() { var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://primary:4840", Redundancy = new RedundancyOptions( Enabled: true, ServiceLevelThreshold: 150, RecheckInterval: TimeSpan.FromSeconds(10)), }; var json = JsonSerializer.Serialize(opts); var roundTripped = JsonSerializer.Deserialize(json); roundTripped.ShouldNotBeNull(); roundTripped!.Redundancy.Enabled.ShouldBeTrue(); roundTripped.Redundancy.ServiceLevelThreshold.ShouldBe((ushort)150); roundTripped.Redundancy.ResolvedRecheckInterval.ShouldBe(TimeSpan.FromSeconds(10)); } [Fact] public void Disabled_redundancy_does_not_failover_on_low_servicelevel() { // Even a value of 0 (unrecoverable per the spec) should be a no-op when the // feature is disabled — the driver shouldn't be reading ServerArray or watching // ServiceLevel at all in that mode. var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://primary:4840", Redundancy = new RedundancyOptions(Enabled: false), }; using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-disabled"); var hookFired = false; drv.RedundancyFailoverHookForTest = (_, _) => { hookFired = true; return Task.FromResult(true); }; drv.InjectServiceLevelDropForTest(0); hookFired.ShouldBeFalse( "Redundancy.Enabled=false means ServiceLevel drops must not trigger failover"); drv.RedundancyFailoverInvocationsForTest.ShouldBe(0); } [Fact] public void ServiceLevel_above_threshold_does_not_trigger_failover() { var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://primary:4840", Redundancy = new RedundancyOptions(Enabled: true, ServiceLevelThreshold: 200), }; using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-healthy"); SeedPeers(drv, "opc.tcp://secondary:4840"); var hookFired = false; drv.RedundancyFailoverHookForTest = (_, _) => { hookFired = true; return Task.FromResult(true); }; // Equal to threshold = healthy boundary; spec semantics treat 200 as healthy. drv.InjectServiceLevelDropForTest(200); // Just above threshold = healthy. drv.InjectServiceLevelDropForTest(220); hookFired.ShouldBeFalse( "ServiceLevel >= threshold must not trigger failover — healthy primary stays put"); } [Fact] public void ServiceLevel_below_threshold_triggers_failover_with_secondary_uri() { var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://primary:4840", Redundancy = new RedundancyOptions(Enabled: true, ServiceLevelThreshold: 200), }; using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-failover"); SeedPeers(drv, "opc.tcp://primary:4840", "opc.tcp://secondary:4840"); SeedActive(drv, "opc.tcp://primary:4840"); string? failoverTarget = null; drv.RedundancyFailoverHookForTest = (uri, _) => { failoverTarget = uri; return Task.FromResult(true); }; drv.InjectServiceLevelDropForTest(50); // Wait for the fire-and-forget Task to complete. The driver dispatches FailoverAsync // via discard — give it a beat to land. Wait(() => failoverTarget is not null); failoverTarget.ShouldBe("opc.tcp://secondary:4840", "the failover path picks the next URI in ServerArray that isn't the active one"); var diags1 = drv.GetHealth().Diagnostics; diags1.ShouldNotBeNull(); diags1!.ShouldContainKey("RedundancyFailoverCount"); diags1["RedundancyFailoverCount"].ShouldBe(1); } [Fact] public void Empty_peer_list_does_not_trigger_failover() { // Upstream with RedundancySupport=None (or one that simply doesn't expose the // ServerUriArray node) leaves _redundancyPeers empty. ServiceLevel drops in that // mode are diagnostic-only — the driver has no peer to swap to. var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://primary:4840", Redundancy = new RedundancyOptions(Enabled: true), }; using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-no-peers"); var hookFired = false; drv.RedundancyFailoverHookForTest = (_, _) => { hookFired = true; return Task.FromResult(true); }; drv.InjectServiceLevelDropForTest(50); hookFired.ShouldBeFalse( "ServerArray empty means there's nowhere to fail over to — drop is informational only"); } [Fact] public void Failover_with_only_active_uri_in_peer_list_does_not_swap_to_self() { // Edge case: the upstream advertises itself in ServerUriArray but no actual peers. // The driver must not try to fail over to the URI it's already on. var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://primary:4840", Redundancy = new RedundancyOptions(Enabled: true), }; using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-self-only"); SeedPeers(drv, "opc.tcp://primary:4840"); SeedActive(drv, "opc.tcp://primary:4840"); var hookFired = false; drv.RedundancyFailoverHookForTest = (_, _) => { hookFired = true; return Task.FromResult(true); }; drv.InjectServiceLevelDropForTest(50); hookFired.ShouldBeFalse( "the only peer in the list is the active URI itself — there's nothing to swap to"); } [Fact] public void Failover_failure_increments_failures_counter_and_keeps_session() { var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://primary:4840", Redundancy = new RedundancyOptions(Enabled: true), }; using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-failure"); SeedPeers(drv, "opc.tcp://primary:4840", "opc.tcp://secondary:4840"); SeedActive(drv, "opc.tcp://primary:4840"); drv.RedundancyFailoverHookForTest = (_, _) => Task.FromResult(false); drv.InjectServiceLevelDropForTest(50); Wait(() => drv.GetHealth().Diagnostics is { } d && d.TryGetValue("RedundancyFailoverFailures", out var f) && f >= 1); var diags = drv.GetHealth().Diagnostics; diags.ShouldNotBeNull(); diags!.ShouldContainKey("RedundancyFailoverFailures"); diags["RedundancyFailoverFailures"].ShouldBe(1); diags["RedundancyFailoverCount"].ShouldBe(0, "a failed swap must not bump the success counter"); } [Fact] public void Repeated_drops_within_recheck_interval_only_failover_once() { var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://primary:4840", Redundancy = new RedundancyOptions( Enabled: true, ServiceLevelThreshold: 200, RecheckInterval: TimeSpan.FromMinutes(5)), }; using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-debounce"); SeedPeers(drv, "opc.tcp://primary:4840", "opc.tcp://secondary:4840"); SeedActive(drv, "opc.tcp://primary:4840"); var calls = 0; drv.RedundancyFailoverHookForTest = (_, _) => { Interlocked.Increment(ref calls); return Task.FromResult(true); }; drv.InjectServiceLevelDropForTest(50); Wait(() => calls >= 1); drv.InjectServiceLevelDropForTest(50); drv.InjectServiceLevelDropForTest(40); // RecheckInterval = 5 minutes — the second + third drops should be suppressed // because the first failover landed inside the window. calls.ShouldBe(1, "RecheckInterval suppresses oscillation around the threshold so a flapping primary doesn't ping-pong"); } [Fact] public void Diagnostics_exposes_redundancy_counters_in_snapshot() { // The `driver-diagnostics` RPC reads through GetHealth(); operators expect the // redundancy counters in the snapshot regardless of whether failover ever fired. var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://primary:4840", Redundancy = new RedundancyOptions(Enabled: true), }; using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-diag"); var d = drv.GetHealth().Diagnostics; d.ShouldNotBeNull(); d!.ShouldContainKey("RedundancyFailoverCount"); d.ShouldContainKey("RedundancyFailoverFailures"); d["RedundancyFailoverCount"].ShouldBe(0); d["RedundancyFailoverFailures"].ShouldBe(0); } // ---- helpers ---- private static void SeedPeers(OpcUaClientDriver drv, params string[] peers) { // The driver normally populates _redundancyPeers from a session ReadValue call. // For unit testing we use reflection to seed the field directly — the alternative // (mocking ISession) brings most of the OPC UA SDK into the test surface. var field = typeof(OpcUaClientDriver).GetField( "_redundancyPeers", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; field.SetValue(drv, (IReadOnlyList)peers); } private static void SeedActive(OpcUaClientDriver drv, string uri) { var diag = drv.DiagnosticsForTest; diag.SetActiveServerUri(uri); } private static void Wait(Func predicate, int timeoutMs = 2000) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (DateTime.UtcNow < deadline) { if (predicate()) return; Thread.Sleep(10); } } }