using Moq; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests; /// /// Driver.OpcUaClient.Browser-002: when the caller cancels during the BrowseNextAsync /// pagination loop, the server-side continuation point must be released /// (releaseContinuationPoints: true) before the cancellation propagates. Mirrors the /// runtime driver's OpcUaClientContinuationPointReleaseTests; no live server needed. /// [Trait("Category", "Unit")] public sealed class OpcUaClientBrowseSessionPaginationTests { private static readonly byte[] FakeContinuationPoint = [0x01, 0x02, 0x03]; private static Mock BuildSession(bool cancelNext) { var mock = new Mock(MockBehavior.Loose); mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext()); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); var firstRef = new ReferenceDescription { NodeId = new ExpandedNodeId(new NodeId(1000u)), BrowseName = new QualifiedName("TestObject"), DisplayName = new LocalizedText("TestObject"), NodeClass = NodeClass.Object, }; mock.Setup(s => s.BrowseAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new BrowseResponse { ResponseHeader = new ResponseHeader(), Results = new BrowseResultCollection { new BrowseResult { ContinuationPoint = FakeContinuationPoint, References = new ReferenceDescriptionCollection { firstRef }, }, }, DiagnosticInfos = new DiagnosticInfoCollection(), }); var emptyPage = new BrowseNextResponse { ResponseHeader = new ResponseHeader(), Results = new BrowseResultCollection { new BrowseResult { ContinuationPoint = Array.Empty(), References = new ReferenceDescriptionCollection(), }, }, DiagnosticInfos = new DiagnosticInfoCollection(), }; var pageSetup = mock.Setup(s => s.BrowseNextAsync( It.IsAny(), It.Is(r => !r), It.IsAny(), It.IsAny())); if (cancelNext) pageSetup.ThrowsAsync(new OperationCanceledException("cancelled mid-page")); else pageSetup.ReturnsAsync(emptyPage); mock.Setup(s => s.BrowseNextAsync( It.IsAny(), It.Is(r => r), It.IsAny(), It.IsAny())) .ReturnsAsync(emptyPage); return mock; } private static OpcUaClientBrowseSession NewSession(Mock mock) => new(mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder); /// Cancellation mid-pagination releases the continuation point exactly once. [Fact] public async Task RootAsync_releases_continuation_point_when_cancelled_mid_pagination() { var mock = BuildSession(cancelNext: true); await using var session = NewSession(mock); await Should.ThrowAsync(async () => await session.RootAsync(TestContext.Current.CancellationToken)); mock.Verify(s => s.BrowseNextAsync( It.IsAny(), It.Is(r => r), It.IsAny(), It.IsAny()), Times.Once); } /// Positive control: an uncancelled pagination never issues a release call. [Fact] public async Task RootAsync_does_not_release_when_pagination_completes() { var mock = BuildSession(cancelNext: false); await using var session = NewSession(mock); var nodes = await session.RootAsync(TestContext.Current.CancellationToken); nodes.Count.ShouldBe(1); mock.Verify(s => s.BrowseNextAsync( It.IsAny(), It.Is(r => !r), It.IsAny(), It.IsAny()), Times.Once); mock.Verify(s => s.BrowseNextAsync( It.IsAny(), It.Is(r => r), It.IsAny(), It.IsAny()), Times.Never); } }