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