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));
}
}