diff --git a/Libraries/Opc.Ua.Client/Browser.cs b/Libraries/Opc.Ua.Client/Browser.cs index ef7c746b62..6ff7674ada 100644 --- a/Libraries/Opc.Ua.Client/Browser.cs +++ b/Libraries/Opc.Ua.Client/Browser.cs @@ -303,7 +303,7 @@ namespace Opc.Ua.Client try { // process any continuation point. - while (continuationPoint != null) + while (continuationPoint?.Length > 0) { ReferenceDescriptionCollection additionalReferences; diff --git a/Tests/Opc.Ua.Client.Tests/BrowserUnitTests.cs b/Tests/Opc.Ua.Client.Tests/BrowserUnitTests.cs new file mode 100644 index 0000000000..6f32b5fc7b --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/BrowserUnitTests.cs @@ -0,0 +1,186 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.Client.Tests +{ + /// + /// Unit tests for the continuation point handling of the browser. + /// + [TestFixture] + [Category("Client")] + [Parallelizable] + public class BrowserUnitTests + { + /// + /// A server which signals "no more references" with an empty (zero length) + /// continuation point must not trigger a BrowseNext call, because such a + /// call is rejected by the server with BadContinuationPointInvalid. + /// + [Test] + [TestCase(new byte[0])] + [TestCase((byte[])null)] + public async Task BrowseAsyncDoesNotCallBrowseNextWithoutContinuationPointAsync( + byte[] continuationPoint) + { + var sessionMock = new Mock(MockBehavior.Strict); + SetupBrowse(sessionMock, CreateReferences("Browsed"), continuationPoint); + + var browser = new Browser(NUnitTelemetryContext.Create()) + { + Session = sessionMock.Object + }; + + ReferenceDescriptionCollection references = await browser + .BrowseAsync(ObjectIds.ObjectsFolder) + .ConfigureAwait(false); + + Assert.That(references, Has.Count.EqualTo(1)); + Assert.That(references[0].DisplayName.Text, Is.EqualTo("Browsed")); + + sessionMock.Verify( + x => x.BrowseNextAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + /// + /// A non empty continuation point is followed until the server stops + /// returning one. + /// + [Test] + public async Task BrowseAsyncFollowsContinuationPointUntilExhaustedAsync() + { + var sessionMock = new Mock(MockBehavior.Strict); + SetupBrowse(sessionMock, CreateReferences("First"), [1, 2, 3]); + SetupBrowseNext(sessionMock, CreateReferences("Second"), []); + + var browser = new Browser(NUnitTelemetryContext.Create()) + { + Session = sessionMock.Object, + ContinueUntilDone = true + }; + + ReferenceDescriptionCollection references = await browser + .BrowseAsync(ObjectIds.ObjectsFolder) + .ConfigureAwait(false); + + Assert.That(references, Has.Count.EqualTo(2)); + Assert.That(references[0].DisplayName.Text, Is.EqualTo("First")); + Assert.That(references[1].DisplayName.Text, Is.EqualTo("Second")); + + sessionMock.Verify( + x => x.BrowseNextAsync( + It.IsAny(), + false, + It.IsAny(), + It.IsAny()), + Times.Once); + } + + private static void SetupBrowse( + Mock sessionMock, + ReferenceDescriptionCollection references, + byte[] continuationPoint) + { + sessionMock + .Setup(x => x.BrowseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new BrowseResponse + { + ResponseHeader = new ResponseHeader(), + Results = CreateResults(references, continuationPoint), + DiagnosticInfos = [] + }); + } + + private static void SetupBrowseNext( + Mock sessionMock, + ReferenceDescriptionCollection references, + byte[] continuationPoint) + { + sessionMock + .Setup(x => x.BrowseNextAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new BrowseNextResponse + { + ResponseHeader = new ResponseHeader(), + Results = CreateResults(references, continuationPoint), + DiagnosticInfos = [] + }); + } + + private static BrowseResultCollection CreateResults( + ReferenceDescriptionCollection references, + byte[] continuationPoint) + { + return + [ + new BrowseResult + { + StatusCode = StatusCodes.Good, + ContinuationPoint = continuationPoint, + References = references + } + ]; + } + + private static ReferenceDescriptionCollection CreateReferences(string displayName) + { + return + [ + new ReferenceDescription + { + NodeId = new ExpandedNodeId(new NodeId(displayName, 1)), + DisplayName = displayName, + BrowseName = new QualifiedName(displayName, 1), + ReferenceTypeId = ReferenceTypeIds.HasComponent, + IsForward = true, + NodeClass = NodeClass.Object, + TypeDefinition = ObjectTypeIds.BaseObjectType + } + ]; + } + } +}