using System.Text.Json; using Moq; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Gitea #763 (root cause of #728) — a BadNoSubscription PublishError with no subscription id that answers a /// publish queued while the driver had just deleted its LAST subscription must not be read as a loss of the /// subscription registered since. Before the fix any anonymous BadNoSubscription with anything tracked /// declared the subscription lost: health Degraded, a BadNoCommunication quality-only annotation on every /// alarm condition and — so a routine unsubscribe → /// resubscribe re-initialised the driver. A genuine loss must still be declared. /// [Trait("Category", "Unit")] public sealed class OpcUaClientStaleNoSubscriptionTests { private const uint BadNoCommunication = 0x80310000u; private const string SpeedRef = "Plant/dev/Speed"; private const string AlarmRef = "Plant/dev/TempHi"; private sealed class ManualClock(DateTimeOffset start) : TimeProvider { public DateTimeOffset Now { get; set; } = start; public override DateTimeOffset GetUtcNow() => Now; public void Advance(TimeSpan by) => Now += by; } private static readonly IReadOnlyList Tags = [ new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true), ]; private sealed record Observed(List Alarms, List Data, List Lost); private static (OpcUaClientDriver Driver, ManualClock Clock, Observed Seen) NewConnectedDriver() { var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-763"); var session = new Mock(MockBehavior.Loose); session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); var clock = new ManualClock(new DateTimeOffset(2026, 11, 9, 12, 0, 0, TimeSpan.Zero)); drv.Clock = clock; drv.SetSessionForTest(session.Object); drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, clock.Now.UtcDateTime, null)); var seen = new Observed([], [], []); drv.OnAlarmEvent += (_, e) => seen.Alarms.Add(e); drv.OnDataChange += (_, e) => seen.Data.Add(e); drv.ConnectionLost += (_, e) => seen.Lost.Add(e); return (drv, clock, seen); } private static void ShouldBeUntouched(OpcUaClientDriver drv, Observed seen, string because) { drv.GetHealth().State.ShouldBe(DriverState.Healthy, because); seen.Lost.ShouldBeEmpty(because); seen.Alarms.ShouldBeEmpty(because); seen.Data.ShouldBeEmpty(because); } private static void ShouldBeDeclaredLost(OpcUaClientDriver drv, Observed seen, string because) { drv.GetHealth().State.ShouldBe(DriverState.Degraded, because); seen.Lost.ShouldHaveSingleItem(because).Reason.ShouldContain("subscription lost"); } /// The #728 wire shape: alarm set released and re-subscribed, then the late error lands. [Fact] public async Task A_stale_BadNoSubscription_after_an_alarm_resubscribe_is_not_a_loss() { var (drv, clock, seen) = NewConnectedDriver(); await using var _ = drv; var first = drv.AddAlarmSubscriptionForTest([AlarmRef]); await drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken); drv.AddAlarmSubscriptionForTest([AlarmRef]); clock.Advance(TimeSpan.FromMilliseconds(850)); // measured on the wire: ~0.85 s after the resubscribe drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); ShouldBeUntouched(drv, seen, "the error answers the deleted subscription's publish, not a loss of the new one"); } [Fact] public async Task A_stale_BadNoSubscription_after_a_data_resubscribe_is_not_a_loss() { var (drv, clock, seen) = NewConnectedDriver(); await using var _ = drv; var first = drv.AddSubscriptionForTest([SpeedRef]); await drv.UnsubscribeAsync(first, TestContext.Current.CancellationToken); drv.AddSubscriptionForTest([SpeedRef]); clock.Advance(TimeSpan.FromSeconds(1)); drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); ShouldBeUntouched(drv, seen, "the error answers the deleted subscription's publish, not a loss of the new one"); } /// Positive control for the window: the same error once it has elapsed is a genuine loss. [Fact] public async Task A_BadNoSubscription_after_the_grace_is_still_a_loss() { var (drv, clock, seen) = NewConnectedDriver(); await using var _ = drv; var first = drv.AddAlarmSubscriptionForTest([AlarmRef]); await drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken); drv.AddAlarmSubscriptionForTest([AlarmRef]); clock.Advance(OpcUaClientDriver.StaleNoSubscriptionGrace); drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); ShouldBeDeclaredLost(drv, seen, "past the grace the server has dropped the new subscription"); seen.Alarms.ShouldHaveSingleItem().QualityStatusCode.ShouldBe(BadNoCommunication); } /// Genuine loss with no recent delete at all — the pre-#763 behaviour, kept. [Fact] public void A_BadNoSubscription_with_no_recent_delete_is_a_loss() { var (drv, _, seen) = NewConnectedDriver(); using var _ = drv; drv.AddAlarmSubscriptionForTest([AlarmRef]); drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); ShouldBeDeclaredLost(drv, seen, "the server holds none of the subscriptions the driver tracks"); seen.Alarms.ShouldHaveSingleItem().QualityStatusCode.ShouldBe(BadNoCommunication); } /// /// Deleting one subscription while another stays tracked never left the server empty, so a BadNoSubscription /// can only mean the survivor is gone — the window must not open. /// [Fact] public async Task Deleting_a_subscription_that_was_not_the_last_does_not_open_the_window() { var (drv, _, seen) = NewConnectedDriver(); await using var _ = drv; drv.AddAlarmSubscriptionForTest([AlarmRef]); var data = drv.AddSubscriptionForTest([SpeedRef]); await drv.UnsubscribeAsync(data, TestContext.Current.CancellationToken); drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); ShouldBeDeclaredLost(drv, seen, "the alarm subscription outlived the delete, so the server should still hold it"); } [Fact] public async Task A_BadNoSubscription_naming_a_subscription_the_driver_no_longer_tracks_is_not_a_loss() { var (drv, clock, seen) = NewConnectedDriver(); await using var _ = drv; var first = drv.AddAlarmSubscriptionForTest([AlarmRef]); await drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken); drv.AddAlarmSubscriptionForTest([AlarmRef]); clock.Advance(TimeSpan.FromMinutes(1)); // well past the grace: attribution is by id, not by time drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription, subscriptionId: 4242); ShouldBeUntouched(drv, seen, "the error names a subscription the driver does not track"); } /// A released session starts with no window: the stamp of the previous session's delete is gone. [Fact] public async Task The_window_does_not_survive_a_session_release() { var (drv, clock, seen) = NewConnectedDriver(); await using var _ = drv; var first = drv.AddAlarmSubscriptionForTest([AlarmRef]); await drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken); await drv.ShutdownAsync(TestContext.Current.CancellationToken); var session = new Mock(MockBehavior.Loose); drv.SetSessionForTest(session.Object); drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, clock.Now.UtcDateTime, null)); drv.AddAlarmSubscriptionForTest([AlarmRef]); drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); ShouldBeDeclaredLost(drv, seen, "the delete belonged to the released session"); } } /// /// Gitea #764 residual: the server's DeleteSubscriptions can be slow (> ). /// The server answers the stale publish only once the delete is done, so the grace runs from the delete's /// COMPLETION, not its start. /// [Trait("Category", "Unit")] public sealed class OpcUaClientSlowDeleteTests { private const string SpeedRef = "Plant/dev/Speed"; private const string AlarmRef = "Plant/dev/TempHi"; private sealed class ManualClock(DateTimeOffset start) : TimeProvider { public DateTimeOffset Now { get; set; } = start; public override DateTimeOffset GetUtcNow() => Now; public void Advance(TimeSpan by) => Now += by; } private static (OpcUaClientDriver Driver, ManualClock Clock, List Lost, TaskCompletionSource Gate, TaskCompletionSource Started) New() { RawTagEntry[] tags = [new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)]; var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = tags }, "opcua-764"); var session = new Mock(MockBehavior.Loose); session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); var clock = new ManualClock(new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero)); drv.Clock = clock; drv.SetSessionForTest(session.Object); drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), tags)); drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, clock.Now.UtcDateTime, null)); var lost = new List(); drv.ConnectionLost += (_, e) => lost.Add(e); var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); drv.DeleteSubscriptionAsync = async (_, _) => { started.TrySetResult(); await gate.Task; }; return (drv, clock, lost, gate, started); } [Fact] public async Task A_slow_data_delete_still_attributes_the_stale_error_to_the_delete() { var (drv, clock, lost, gate, started) = New(); await using var _ = drv; var first = drv.AddSubscriptionForTest([SpeedRef]); var unsub = drv.UnsubscribeAsync(first, TestContext.Current.CancellationToken); await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); clock.Advance(TimeSpan.FromSeconds(30)); // the delete takes far longer than the grace gate.SetResult(); await unsub; drv.AddSubscriptionForTest([SpeedRef]); clock.Advance(TimeSpan.FromMilliseconds(850)); drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); lost.ShouldBeEmpty("the grace runs from the delete's completion, not its start"); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task A_slow_alarm_delete_still_attributes_the_stale_error_to_the_delete() { var (drv, clock, lost, gate, started) = New(); await using var _ = drv; var first = drv.AddAlarmSubscriptionForTest([AlarmRef]); var unsub = drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken); await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); clock.Advance(TimeSpan.FromSeconds(30)); gate.SetResult(); await unsub; drv.AddAlarmSubscriptionForTest([AlarmRef]); clock.Advance(TimeSpan.FromMilliseconds(850)); drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); lost.ShouldBeEmpty("the grace runs from the delete's completion, not its start"); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// Positive control: a slow delete does not extend the window beyond grace after completion. [Fact] public async Task After_a_slow_delete_completes_the_grace_still_expires() { var (drv, clock, lost, gate, started) = New(); await using var _ = drv; var first = drv.AddSubscriptionForTest([SpeedRef]); var unsub = drv.UnsubscribeAsync(first, TestContext.Current.CancellationToken); await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); clock.Advance(TimeSpan.FromSeconds(30)); gate.SetResult(); await unsub; drv.AddSubscriptionForTest([SpeedRef]); clock.Advance(OpcUaClientDriver.StaleNoSubscriptionGrace); drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); lost.ShouldHaveSingleItem().Reason.ShouldContain("subscription lost"); } }