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; /// /// Regression coverage for Driver.OpcUaClient-016: when the caller cancels during /// the BrowseNextAsync pagination loop inside BrowseRecursiveAsync, the /// server-side continuation point must be released via /// BrowseNextAsync(releaseContinuationPoints: true) before the /// propagates. Without the release the server /// retains the cursor open until the session is closed (a resource leak). This is the /// same pattern the Browser subagent recorded as Driver.OpcUaClient.Browser-002 and /// noted also exists in the runtime pagination loop. /// [Trait("Category", "Unit")] public sealed class OpcUaClientContinuationPointReleaseTests { // A fake non-empty continuation point so the pagination loop can be triggered. private static readonly byte[] FakeContinuationPoint = [0x01, 0x02, 0x03]; /// /// Builds a loose mock where: /// /// BrowseAsync returns a result with one Object-class child and a /// non-empty continuation point so the pagination loop is entered. /// BrowseNextAsync(releaseContinuationPoints: false) throws /// — simulating cancellation mid-page. /// BrowseNextAsync(releaseContinuationPoints: true) returns an empty /// response — the release call the fixed driver should issue before rethrowing. /// /// private static Mock BuildCancellingSessionMock() { var mock = new Mock(MockBehavior.Loose); mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext()); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); // BrowseAsync — return one Object child plus a non-empty continuation point so // the pagination loop is entered. The child node is irrelevant; the loop is // what matters. var firstRef = new ReferenceDescription { NodeId = new ExpandedNodeId(new NodeId(1000, 2), "opc.tcp://test"), BrowseName = new QualifiedName("TestObject", 2), DisplayName = new LocalizedText("TestObject"), NodeClass = NodeClass.Object, }; var browseResult = new BrowseResult { ContinuationPoint = FakeContinuationPoint, References = new ReferenceDescriptionCollection { firstRef }, }; var browseResp = new BrowseResponse { ResponseHeader = new ResponseHeader(), Results = new BrowseResultCollection { browseResult }, DiagnosticInfos = new DiagnosticInfoCollection(), }; mock.Setup(s => s.BrowseAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(browseResp); // BrowseNextAsync(release=false) — pagination call: throw cancellation. mock.Setup(s => s.BrowseNextAsync( It.IsAny(), It.Is(release => !release), It.IsAny(), It.IsAny())) .ThrowsAsync(new OperationCanceledException("ct cancelled during BrowseNext")); // BrowseNextAsync(release=true) — the release call the fixed driver must make. // BrowseNextResponse.Results shares BrowseResult (same as BrowseResponse.Results); // there is no separate BrowseNextResult type in the OPC UA SDK. var emptyReleaseResult = new BrowseResult { ContinuationPoint = Array.Empty(), References = new ReferenceDescriptionCollection(), }; var releaseResp = new BrowseNextResponse { ResponseHeader = new ResponseHeader(), Results = new BrowseResultCollection { emptyReleaseResult }, DiagnosticInfos = new DiagnosticInfoCollection(), }; mock.Setup(s => s.BrowseNextAsync( It.IsAny(), It.Is(release => release), It.IsAny(), It.IsAny())) .ReturnsAsync(releaseResp); return mock; } /// /// When the caller's fires mid-pagination, /// DiscoverAsync must call BrowseNextAsync(releaseContinuationPoints: true) /// before the exception propagates, so the server-side cursor is freed rather than /// held open until session close. /// [Fact] public async Task DiscoverAsync_releases_continuation_point_when_cancelled_mid_pagination() { var ct = TestContext.Current.CancellationToken; using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cp-release"); var sessionMock = BuildCancellingSessionMock(); drv.SetSessionForTest(sessionMock.Object); var builder = new NullAddressSpaceBuilder(); // DiscoverAsync should throw (OperationCanceledException propagates after release), // but the release BrowseNextAsync(release=true) must be called first. await Should.ThrowAsync(async () => await drv.DiscoverAsync(builder, ct)); // Verify the release call was made exactly once with releaseContinuationPoints=true. sessionMock.Verify(s => s.BrowseNextAsync( It.IsAny(), It.Is(release => release), It.IsAny(), It.IsAny()), Times.Once, "DiscoverAsync must release the server-side continuation point on cancellation " + "(Driver.OpcUaClient-016 / Browser-002 cross-module finding)"); } /// /// Transient browse failure (non-cancellation exception) must NOT attempt a release — /// the server already cleaned up (the request never reached the page-fetch state), and /// calling BrowseNext on a bad continuation point would add noise to the server log. /// BrowseRecursiveAsync's existing catch swallows transient failures silently; /// we should not change that behaviour. /// [Fact] public async Task DiscoverAsync_does_not_release_continuation_point_on_non_cancel_browse_failure() { var ct = TestContext.Current.CancellationToken; using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cp-norelease"); var mock = new Mock(MockBehavior.Loose); mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext()); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); // BrowseAsync — return a result with a continuation point so the loop is entered. var firstRef = new ReferenceDescription { NodeId = new ExpandedNodeId(new NodeId(1001, 2), "opc.tcp://test"), BrowseName = new QualifiedName("TestObj2", 2), DisplayName = new LocalizedText("TestObj2"), NodeClass = NodeClass.Object, }; var browseResult = new BrowseResult { ContinuationPoint = FakeContinuationPoint, References = new ReferenceDescriptionCollection { firstRef }, }; mock.Setup(s => s.BrowseAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new BrowseResponse { ResponseHeader = new ResponseHeader(), Results = new BrowseResultCollection { browseResult }, DiagnosticInfos = new DiagnosticInfoCollection(), }); // BrowseNextAsync — throw a non-cancellation transport error. mock.Setup(s => s.BrowseNextAsync( It.IsAny(), It.Is(release => !release), It.IsAny(), It.IsAny())) .ThrowsAsync(new ServiceResultException(StatusCodes.BadCommunicationError)); drv.SetSessionForTest(mock.Object); // DiscoverAsync catches the transient failure and continues (returns normally since // the root sub-tree failure just skips that branch). await drv.DiscoverAsync(new NullAddressSpaceBuilder(), ct); // The release call must NOT be made for a transport error — only for cancellation. mock.Verify(s => s.BrowseNextAsync( It.IsAny(), It.Is(release => release), It.IsAny(), It.IsAny()), Times.Never, "A transient transport failure must not trigger a release BrowseNext call"); } /// Minimal no-op address space builder for discovery tests. private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder { /// public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; /// public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) => new StubHandle(); /// public void AddProperty(string browseName, DriverDataType dataType, object? value) { } /// public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { } private sealed class StubHandle : IVariableHandle { public string FullReference => "stub"; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException(); } } }