using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; using ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Fakes; namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests; /// /// Lane K review I1: a keep-alive failover belongs to the connection it started under. A user Disconnect + Connect /// while it is still connecting starts a new connection epoch; the stale failover must abandon itself — never /// overwrite the user's new session, never flip its state. Every step is gate-driven (no sleeps). /// public sealed class FailoverEpochTests : IDisposable { private const string Primary = "opc.tcp://primary:4840"; private const string Backup = "opc.tcp://backup:4840"; private static readonly TimeSpan Wait = TimeSpan.FromSeconds(10); private readonly FakeSessionFactory _factory = new(); private readonly OpcUaClientService _service; private readonly List _states = []; public FailoverEpochTests() { _service = new OpcUaClientService(new FakeApplicationConfigurationFactory(), new FakeEndpointDiscovery(), _factory); _service.ConnectionStateChanged += (_, e) => { lock (_states) _states.Add(e.NewState); }; } public void Dispose() => _service.Dispose(); private static ConnectionSettings Settings() => new() { EndpointUrl = Primary, FailoverUrls = [Backup], SessionTimeoutSeconds = 60 }; /// Connects S1, then holds a keep-alive failover inside its connect attempt. private async Task<(FakeSessionAdapter S1, Task Failover, TaskCompletionSource Gate)> StartHeldFailoverAsync() { var s1 = new FakeSessionAdapter(); _factory.EnqueueSession(s1); await _service.ConnectAsync(Settings()); var gate = new TaskCompletionSource(); _factory.CreateGate = gate; s1.SimulateKeepAlive(false); var failover = _service.LastFailover.ShouldNotBeNull(); await _factory.CreateEntered.Task.WaitAsync(Wait); return (s1, failover, gate); } /// The stale failover's attempt SUCCEEDS after the user reconnected: its session is disposed, the user's stays. [Fact] public async Task A_stale_failover_that_connects_does_not_replace_the_users_new_session() { var (_, failover, gate) = await StartHeldFailoverAsync(); var userSession = new FakeSessionAdapter(); var failoverSession = new FakeSessionAdapter(); _factory.EnqueueSession(userSession); // dequeued by the user's Connect (the failover is still held) _factory.EnqueueSession(failoverSession); // dequeued by the failover once released await _service.DisconnectAsync(); await _service.ConnectAsync(Settings()); lock (_states) _states.Clear(); gate.SetResult(); await failover.WaitAsync(Wait); failoverSession.Disposed.ShouldBeTrue(); userSession.Disposed.ShouldBeFalse(); _service.IsConnected.ShouldBeTrue(); _service.CurrentConnectionInfo!.EndpointUrl.ShouldBe(Primary); lock (_states) _states.ShouldBeEmpty(); // the stale failover made no state transition at all } /// The stale failover's attempts all FAIL after the user reconnected: the user's connection stays Connected. [Fact] public async Task A_stale_failover_that_fails_does_not_flip_the_users_connection_to_Disconnected() { var (_, failover, gate) = await StartHeldFailoverAsync(); _factory.EnqueueSession(new FakeSessionAdapter()); await _service.DisconnectAsync(); await _service.ConnectAsync(Settings()); lock (_states) _states.Clear(); var createsBefore = _factory.CreateCallCount; _factory.ThrowOnCreate = true; gate.SetResult(); await failover.WaitAsync(Wait); _service.IsConnected.ShouldBeTrue(); lock (_states) _states.ShouldBeEmpty(); // It stopped at the abandoned attempt instead of trying every remaining endpoint (review m5). _factory.CreateCallCount.ShouldBe(createsBefore); // And the user can still disconnect / reconnect normally afterwards. _factory.ThrowOnCreate = false; await _service.DisconnectAsync(); await _service.ConnectAsync(Settings()); _service.IsConnected.ShouldBeTrue(); } /// Positive control: an undisturbed failover still reconnects to the backup. [Fact] public async Task A_genuine_failover_still_reconnects() { var (_, failover, gate) = await StartHeldFailoverAsync(); _factory.EnqueueSession(new FakeSessionAdapter()); gate.SetResult(); await failover.WaitAsync(Wait); _service.IsConnected.ShouldBeTrue(); _service.CurrentConnectionInfo!.EndpointUrl.ShouldBe(Backup); lock (_states) _states.ShouldContain(ConnectionState.Reconnecting); } /// /// A late bad keep-alive from the PREVIOUS connection's session must not start a failover against the new one. /// Positive control: the same signal from the current session does (the genuine-failover test above). /// [Fact] public async Task A_keep_alive_from_a_previous_connection_does_not_fail_over_the_current_one() { var s1 = new FakeSessionAdapter(); _factory.EnqueueSession(s1); await _service.ConnectAsync(Settings()); await _service.DisconnectAsync(); _factory.EnqueueSession(new FakeSessionAdapter()); await _service.ConnectAsync(Settings()); var creates = _factory.CreateCallCount; lock (_states) _states.Clear(); s1.SimulateKeepAlive(false); if (_service.LastFailover is { } t) await t.WaitAsync(Wait); _factory.CreateCallCount.ShouldBe(creates); lock (_states) _states.ShouldBeEmpty(); _service.IsConnected.ShouldBeTrue(); } /// Review m8: re-entrant bad keep-alives do not overwrite the seam with an already-completed no-op. [Fact] public async Task Reentrant_keep_alives_leave_the_seam_on_the_running_failover() { var (s1, failover, gate) = await StartHeldFailoverAsync(); s1.SimulateKeepAlive(false); s1.SimulateKeepAlive(false); _service.LastFailover.ShouldBeSameAs(failover); failover.IsCompleted.ShouldBeFalse(); _factory.EnqueueSession(new FakeSessionAdapter()); gate.SetResult(); await failover.WaitAsync(Wait); } /// /// Review m5: an Unsubscribe whose server-side remove fails (the session died under it) still drops the /// bookkeeping, so a failover does not replay an item the caller asked to stop. Proven by a re-subscribe of the /// same node creating a fresh monitored item instead of returning "already subscribed". /// [Fact] public async Task A_failed_unsubscribe_still_forgets_the_item() { var sub = new FakeSubscriptionAdapter(); _factory.EnqueueSession(new FakeSessionAdapter { NextSubscription = sub }); await _service.ConnectAsync(Settings()); var node = new Opc.Ua.NodeId("ns=2;s=X"); await _service.SubscribeAsync(node); sub.ThrowOnRemove = true; await Should.ThrowAsync(() => _service.UnsubscribeAsync(node)); sub.ThrowOnRemove = false; await _service.SubscribeAsync(node); sub.AddDataChangeCount.ShouldBe(2); } }