/* ======================================================================== * Copyright (c) 2005-2026 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; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; using Opc.Ua.Tests; namespace Opc.Ua.Client.Tests { [TestFixture] [Category("Client")] [Category("SessionClientExtensions")] [SetCulture("en-us")] [SetUICulture("en-us")] public sealed class SessionClientExtensionsTests { [Test] public async Task ReadValuesAsyncWithMatchingTypesReturnsValuesAndErrorsAsync() { using ISession session = CreateSession( readResponseFactory: _ => new ReadResponse { ResponseHeader = new ResponseHeader(), Results = [ new DataValue(new Variant(13u)), new DataValue(new Variant("hello")) ], DiagnosticInfos = [] }); (ArrayOf values, ArrayOf errors) = await session.ReadValuesAsync( [new NodeId(1, 0), new NodeId(2, 0)], [TypeInfo.Scalars.UInt32, TypeInfo.Scalars.String]).ConfigureAwait(false); Assert.That(values.Count, Is.EqualTo(2)); Assert.That(values[0].TryGetValue(out uint first), Is.True); Assert.That(first, Is.EqualTo(13u)); Assert.That(values[1].TryGetValue(out string? second), Is.True); Assert.That(second, Is.EqualTo("hello")); Assert.That(errors[0].StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(errors[1].StatusCode, Is.EqualTo(StatusCodes.Good)); } [Test] public async Task ReadValuesAsyncWithMismatchedTypesReturnsTypeMismatchErrorsAsync() { using ISession session = CreateSession( readResponseFactory: _ => new ReadResponse { ResponseHeader = new ResponseHeader(), Results = [ new DataValue(new Variant(13u)), new DataValue(new Variant("hello")) ], DiagnosticInfos = [] }); (ArrayOf values, ArrayOf errors) = await session.ReadValuesAsync( [new NodeId(1, 0), new NodeId(2, 0)], [TypeInfo.Scalars.String, TypeInfo.Scalars.UInt32]).ConfigureAwait(false); Assert.That(values.Count, Is.EqualTo(2)); Assert.That(errors[0].StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); Assert.That(errors[1].StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); } [Test] public async Task BrowseAsyncWithGoodResultReturnsContinuationAndReferencesAsync() { ReferenceDescription reference = new() { NodeId = new ExpandedNodeId(new NodeId(2, 0)), BrowseName = QualifiedName.From("Child"), ReferenceTypeId = ReferenceTypeIds.Organizes, IsForward = true }; using ISession session = CreateSession( browseResponseFactory: _ => new BrowseResponse { ResponseHeader = new ResponseHeader(), Results = [ new BrowseResult { StatusCode = StatusCodes.Good, ContinuationPoint = ByteString.From([1, 2, 3]), References = [reference] } ], DiagnosticInfos = [] }); (ResponseHeader responseHeader, ByteString continuationPoint, ArrayOf references) = await session.BrowseAsync( null, null, new NodeId(1, 0), 0, BrowseDirection.Forward, ReferenceTypeIds.Organizes, false, 0).ConfigureAwait(false); Assert.That(responseHeader, Is.Not.Null); Assert.That(continuationPoint, Is.EqualTo(ByteString.From([1, 2, 3]))); Assert.That(references, Has.Count.EqualTo(1)); Assert.That(references[0].BrowseName, Is.EqualTo(QualifiedName.From("Child"))); } [Test] public void BrowseWithBadResultThrowsServiceResultException() { using ISession session = CreateSession( browseResponseFactory: _ => new BrowseResponse { ResponseHeader = new ResponseHeader(), Results = [ new BrowseResult { StatusCode = StatusCodes.BadUnexpectedError, References = [] } ], DiagnosticInfos = [] }); Assert.ThrowsAsync(async () => await session.BrowseAsync( null, null, new NodeId(1, 0), 0, BrowseDirection.Forward, ReferenceTypeIds.Organizes, false, 0).ConfigureAwait(false)); } [Test] public async Task BrowseNextAsyncWithGoodResultReturnsRevisedContinuationAndReferencesAsync() { ReferenceDescription reference = new() { NodeId = new ExpandedNodeId(new NodeId(2, 0)), BrowseName = QualifiedName.From("Child"), ReferenceTypeId = ReferenceTypeIds.Organizes, IsForward = true }; using ISession session = CreateSession( browseNextResponseFactory: _ => new BrowseNextResponse { ResponseHeader = new ResponseHeader(), Results = [ new BrowseResult { StatusCode = StatusCodes.Good, ContinuationPoint = ByteString.From([4, 5, 6]), References = [reference] } ], DiagnosticInfos = [] }); (ResponseHeader responseHeader, ByteString revisedContinuationPoint, ArrayOf references) = await session.BrowseNextAsync( null, false, ByteString.From([1, 2, 3])).ConfigureAwait(false); Assert.That(responseHeader, Is.Not.Null); Assert.That(revisedContinuationPoint, Is.EqualTo(ByteString.From([4, 5, 6]))); Assert.That(references, Has.Count.EqualTo(1)); Assert.That(references[0].BrowseName, Is.EqualTo(QualifiedName.From("Child"))); } [Test] public void BrowseNextWithBadResultThrowsServiceResultException() { using ISession session = CreateSession( browseNextResponseFactory: _ => new BrowseNextResponse { ResponseHeader = new ResponseHeader(), Results = [ new BrowseResult { StatusCode = StatusCodes.BadUnexpectedError, References = [] } ], DiagnosticInfos = [] }); Assert.ThrowsAsync(async () => await session.BrowseNextAsync( null, false, ByteString.From([1, 2, 3])).ConfigureAwait(false)); } [Test] public async Task ReadBytesAsyncWithSmallChunkReturnsChunkAsync() { using ISession session = CreateSession( readResponseFactory: _ => new ReadResponse { ResponseHeader = new ResponseHeader(), Results = [new DataValue(new Variant(ByteString.From([1, 2, 3])))], DiagnosticInfos = [] }); ByteString result = await session.ReadBytesAsync(new NodeId(1, 0), 10).ConfigureAwait(false); Assert.That(result.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 })); } [Test] public void ReadBytesAsyncWithChunkLongerThanRangeThrows() { // A server ignoring the IndexRange returns the whole value on every // read; the client must not loop appending it forever (L3-5). int reads = 0; using ISession session = CreateSession( readResponseFactory: _ => { reads++; return new ReadResponse { ResponseHeader = new ResponseHeader(), Results = [new DataValue(new Variant(ByteString.From(new byte[12])))], DiagnosticInfos = [] }; }); ServiceResultException sre = Assert.ThrowsAsync( async () => await session.ReadBytesAsync(new NodeId(1, 0), 10).ConfigureAwait(false)); Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnknownResponse)); Assert.That(reads, Is.EqualTo(1)); } [Test] public async Task ReadBytesAsyncWithChunkedResponseConcatenatesChunksAsync() { using ISession session = CreateSession(); var capturedRequests = new List(); int responseIndex = 0; var readSetup = Mock.Get(session); readSetup .Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .Returns, CancellationToken>( (_, _, _, requests, _) => { capturedRequests.Add(requests[0]); ReadResponse response = responseIndex++ switch { 0 => new ReadResponse { ResponseHeader = new ResponseHeader(), Results = [ new DataValue(new Variant( ByteString.From([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))) ], DiagnosticInfos = [] }, 1 => new ReadResponse { ResponseHeader = new ResponseHeader(), Results = [new DataValue(new Variant(ByteString.From([11, 12, 13])))], DiagnosticInfos = [] }, _ => throw new InvalidOperationException("Unexpected additional chunk read.") }; return new ValueTask(response); }); var nodeId = new NodeId(1, 0); ByteString result = await session.ReadBytesAsync(nodeId, 10).ConfigureAwait(false); Assert.That( result.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 })); Assert.That(capturedRequests, Has.Count.EqualTo(2)); Assert.Multiple(() => { Assert.That(capturedRequests[0].NodeId, Is.EqualTo(nodeId)); Assert.That(capturedRequests[0].AttributeId, Is.EqualTo(Attributes.Value)); Assert.That(capturedRequests[0].DataEncoding, Is.EqualTo(QualifiedName.Null)); Assert.That(capturedRequests[0].IndexRange, Is.EqualTo("0:9")); Assert.That(capturedRequests[1].NodeId, Is.EqualTo(nodeId)); Assert.That(capturedRequests[1].AttributeId, Is.EqualTo(Attributes.Value)); Assert.That(capturedRequests[1].DataEncoding, Is.EqualTo(QualifiedName.Null)); Assert.That(capturedRequests[1].IndexRange, Is.EqualTo("10:19")); }); readSetup.Verify(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Exactly(2)); } [Test] public async Task CallAsyncWithGoodResultReturnsOutputArgumentsAsync() { var capturedRequests = ArrayOf.Empty; using ISession session = CreateSession( callResponseFactory: requests => { capturedRequests = requests; return new CallResponse { ResponseHeader = new ResponseHeader(), Results = [ new CallMethodResult { StatusCode = StatusCodes.Good, OutputArguments = [Variant.From(42u)] } ], DiagnosticInfos = [] }; }); ArrayOf outputArguments = await session.CallAsync( new NodeId(1, 0), new NodeId(2, 0), default, new Variant("input")).ConfigureAwait(false); Assert.That(capturedRequests.Count, Is.EqualTo(1)); Assert.That(capturedRequests[0].ObjectId, Is.EqualTo(new NodeId(1, 0))); Assert.That(capturedRequests[0].MethodId, Is.EqualTo(new NodeId(2, 0))); Assert.That(outputArguments.Count, Is.EqualTo(1)); Assert.That(outputArguments[0].TryGetValue(out uint value), Is.True); Assert.That(value, Is.EqualTo(42u)); } [Test] public void CallWithBadResultThrowsServiceResultException() { using ISession session = CreateSession( callResponseFactory: _ => new CallResponse { ResponseHeader = new ResponseHeader(), Results = [ new CallMethodResult { StatusCode = StatusCodes.BadUnexpectedError, OutputArguments = [] } ], DiagnosticInfos = [] }); Assert.ThrowsAsync(async () => await session.CallAsync( new NodeId(1, 0), new NodeId(2, 0), default, new Variant("input")).ConfigureAwait(false)); } [Test] public async Task ResendDataAsyncReturnsStatusPerSubscriptionAsync() { var capturedRequests = ArrayOf.Empty; using ISession session = CreateSession( callResponseFactory: requests => { capturedRequests = requests; return new CallResponse { ResponseHeader = new ResponseHeader(), Results = [ new CallMethodResult { StatusCode = StatusCodes.Good }, new CallMethodResult { StatusCode = StatusCodes.BadUnexpectedError } ], DiagnosticInfos = [] }; }); ArrayOf errors = await session.ResendDataAsync([1u, 2u]).ConfigureAwait(false); Assert.That(capturedRequests, Has.Count.EqualTo(2)); Assert.Multiple(() => { Assert.That(capturedRequests[0].ObjectId, Is.EqualTo(ObjectIds.Server)); Assert.That(capturedRequests[0].MethodId, Is.EqualTo(MethodIds.Server_ResendData)); Assert.That(capturedRequests[0].InputArguments, Has.Count.EqualTo(1)); Assert.That(capturedRequests[0].InputArguments[0].TryGetValue(out uint first), Is.True); Assert.That(first, Is.EqualTo(1u)); Assert.That(capturedRequests[1].ObjectId, Is.EqualTo(ObjectIds.Server)); Assert.That(capturedRequests[1].MethodId, Is.EqualTo(MethodIds.Server_ResendData)); Assert.That(capturedRequests[1].InputArguments, Has.Count.EqualTo(1)); Assert.That(capturedRequests[1].InputArguments[0].TryGetValue(out uint second), Is.True); Assert.That(second, Is.EqualTo(2u)); }); Assert.That(errors.Count, Is.EqualTo(2)); Assert.That(errors[0].StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(errors[1].StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError)); } [Test] public async Task ReleaseContinuationPointAsyncLogsAndSwallowsAFailureAsync() { var failure = new ServiceResultException(StatusCodes.BadSessionIdInvalid); var session = new Mock(); session.Setup(s => s.BrowseNextAsync( It.IsAny(), true, It.IsAny>(), It.IsAny())) .ThrowsAsync(failure); var logger = new Mock(); logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); await session.Object .ReleaseContinuationPointAsync(ByteString.From([1, 2, 3]), logger.Object) .ConfigureAwait(false); #pragma warning disable CA1873 // Moq Verify on ILogger.Log is an expression tree, not an executed logging call. logger.Verify( l => l.Log( LogLevel.Warning, It.Is(eventId => eventId.Name == "ReleaseContinuationPointFailed"), It.IsAny(), failure, It.IsAny>()), Times.Once, "a failed release must be visible, not silently dropped"); #pragma warning restore CA1873 } private static ISession CreateSession( Func, ReadResponse>? readResponseFactory = null, Func, BrowseResponse>? browseResponseFactory = null, Func, BrowseNextResponse>? browseNextResponseFactory = null, Func, CallResponse>? callResponseFactory = null) { ITelemetryContext telemetry = NUnitTelemetryContext.Create(); var messageContext = ServiceMessageContext.Create(telemetry); messageContext.MaxByteStringLength = 1024; var session = new Mock(MockBehavior.Loose); session.SetupGet(s => s.MessageContext).Returns(messageContext); session.SetupGet(s => s.NamespaceUris).Returns(messageContext.NamespaceUris); session.SetupGet(s => s.ServerUris).Returns(messageContext.ServerUris); if (readResponseFactory != null) { session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .Returns, CancellationToken>( (_, _, _, readValueIds, _) => new ValueTask(readResponseFactory(readValueIds))); } if (browseResponseFactory != null) { session.Setup(s => s.BrowseAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .Returns, CancellationToken>( (_, _, _, browseDescriptions, _) => new ValueTask(browseResponseFactory(browseDescriptions))); } if (browseNextResponseFactory != null) { session.Setup(s => s.BrowseNextAsync( It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .Returns, CancellationToken>( (_, _, continuationPoints, _) => new ValueTask(browseNextResponseFactory(continuationPoints))); } if (callResponseFactory != null) { session.Setup(s => s.CallAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .Returns, CancellationToken>( (_, requests, _) => new ValueTask(callResponseFactory(requests))); } return session.Object; } } }