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; /// /// Review 2026-10-21 R06:O-1 — a data SubscribeAsync whose wire create throws used to leave the /// half-built on the session (untracked, so neither Unsubscribe nor Shutdown /// ever deleted it). The host's reconcile retries every health tick, so a server that refused the create /// accumulated one live subscription per retry. The alarm path already cleaned up; the data path now does too. /// /// /// A Moq session never attaches the subscription (AddSubscription is a no-op), so the SDK's /// Subscription.CreateAsync throws — the exact "create failed after AddSubscription" shape. /// [Trait("Category", "Unit")] public sealed class OpcUaClientSubscribeCleanupTests { private const string SpeedRef = "Plant/dev/Speed"; private static readonly IReadOnlyList Tags = [new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)]; private static (OpcUaClientDriver Driver, Mock Session) NewConnectedDriver() { var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o1"); var session = new Mock(MockBehavior.Loose); session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); drv.SetSessionForTest(session.Object); drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); return (drv, session); } /// A failed data subscribe removes the subscription it added to the session, then rethrows. [Fact] public async Task A_failed_data_subscribe_removes_the_half_built_subscription_from_the_session() { var (drv, session) = NewConnectedDriver(); using var _ = drv; await Should.ThrowAsync(() => drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken)); session.Verify(s => s.AddSubscription(It.IsAny()), Times.Once); session.Verify(s => s.RemoveSubscriptionAsync(It.IsAny(), It.IsAny()), Times.Once, "the subscription added before the failing create must not stay on the session"); } /// /// Positive control for the observation above: the alarm path (which always cleaned up) is seen removing /// its subscription through the same mock, so a "Times.Once" pass on the data path is not vacuous. /// [Fact] public async Task Positive_control_the_alarm_path_is_observed_removing_its_half_built_subscription() { var (drv, session) = NewConnectedDriver(); using var _ = drv; await Should.ThrowAsync(() => drv.SubscribeAlarmsAsync([SpeedRef], TestContext.Current.CancellationToken)); session.Verify(s => s.RemoveSubscriptionAsync(It.IsAny(), It.IsAny()), Times.Once); } /// Repeated failing subscribes (the host's reconcile retry) each clean up their own subscription. [Fact] public async Task Each_retry_of_a_failing_subscribe_cleans_up_after_itself() { var (drv, session) = NewConnectedDriver(); using var _ = drv; for (var i = 0; i < 3; i++) await Should.ThrowAsync(() => drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken)); session.Verify(s => s.AddSubscription(It.IsAny()), Times.Exactly(3)); session.Verify(s => s.RemoveSubscriptionAsync(It.IsAny(), It.IsAny()), Times.Exactly(3)); } }