diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverReconnectWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverReconnectWireTests.cs new file mode 100644 index 00000000..1f2413da --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverReconnectWireTests.cs @@ -0,0 +1,80 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Reconnect + resume leg of the OpcUaClient driver's wire coverage (Gitea #622): the in-process server is +/// stopped and a fresh one is booted on the SAME port / application URI / PKI store — a process restart as the +/// driver sees it, with an empty address space the test then re-creates. The driver must detect the loss, +/// rebuild a session, and its existing subscription must deliver again with no caller action. +/// +[Trait("Category", "Integration")] +public sealed class OpcUaClientDriverReconnectWireTests +{ + private static async Task WaitAsync(Func condition, string because, TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(60)); + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(50); + } + condition().ShouldBeTrue(because); + } + + [Fact] + public async Task After_a_server_restart_the_driver_reconnects_and_the_subscription_resumes_delivering() + { + var ct = TestContext.Current.CancellationToken; + var rig = await OpcUaClientDriverWireTests.StartRigAsync("drv-reconnect", ct); + await using var _rig = rig; + OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 10.0f); + + await using var drv = OpcUaClientDriverWireTests.NewDriver( + rig, "wire-reconnect", reconnectPeriod: TimeSpan.FromSeconds(1)); + await drv.InitializeAsync("{}", ct); + + var seen = new List(); + drv.OnDataChange += (_, e) => + { + if (e.FullReference == OpcUaClientDriverWireTests.SpeedRef && e.Snapshot.StatusCode == 0u) + lock (seen) seen.Add(Convert.ToSingle(e.Snapshot.Value)); + }; + var states = new List(); + drv.OnHostStatusChanged += (_, e) => { lock (states) states.Add(e.NewState); }; + + await drv.SubscribeAsync([OpcUaClientDriverWireTests.SpeedRef], TimeSpan.FromMilliseconds(100), ct); + await WaitAsync(() => { lock (seen) return seen.Contains(10.0f); }, "initial value must arrive before the outage"); + + // Outage: the loss must be DETECTED (host leaves Running) while the server is genuinely down. + await rig.RestartAsync(ct, whileDown: async () => + { + await WaitAsync( + () => { lock (states) return states.Any(s => s != HostState.Running); }, + "the driver must notice the server is gone", TimeSpan.FromSeconds(30)); + }); + + // The new server is empty: redeploy the nodes and publish a value no earlier delivery could have carried. + rig.EnsureRawVariable("Speed", "Float", writable: true); + rig.EnsureRawVariable("Counter", "Int32", writable: false); + OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 77.0f); + + await WaitAsync(() => drv.GetHostStatuses().Single().State == HostState.Running, + "the driver must reconnect after the server is back"); + await WaitAsync(() => { lock (seen) return seen.Contains(77.0f); }, + "the pre-outage subscription must resume and deliver the post-restart value"); + + // Ongoing delivery, not just a one-off initial value. + OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 88.0f); + await WaitAsync(() => { lock (seen) return seen.Contains(88.0f); }, + "a later change must also reach the resumed subscription"); + + // And the data plane works for plain reads on the new session. + var back = await drv.ReadAsync([OpcUaClientDriverWireTests.SpeedRef], ct); + back[0].StatusCode.ShouldBe(0u); + Convert.ToSingle(back[0].Value).ShouldBe(88.0f); + } +}