/* Copyright (c) 2026 The OPC Foundation, Inc. All rights reserved.
* Licensed under the MIT License. See LICENSE.txt in the project root. */
using System;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Client.Tests
{
///
/// The result-bearing method path must not inherit the retrying proxy fallback.
///
[TestFixture]
[Category("Client")]
[Parallelizable]
public sealed class ObjectTypeClientResultTests
{
[SetUp]
public void SetUp()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
m_context = ServiceMessageContext.Create(telemetry);
m_context.NamespaceUris.GetIndexOrAppend(kNamespace);
m_session = new Mock(MockBehavior.Strict);
m_session.SetupGet(s => s.MessageContext).Returns(m_context);
m_client = new ResultProxy(m_session.Object, new NodeId("object", 1), telemetry);
}
[TestCaseSource(nameof(s_methodStatuses))]
public async Task CallWithResultAsyncPreservesMethodAndArgumentResultsWithoutRetryAsync(StatusCode status)
{
using var cancellation = new CancellationTokenSource();
var argumentDiagnostic = new DiagnosticInfo { AdditionalInfo = "argument detail" };
var expected = new CallMethodResult
{
StatusCode = status,
InputArgumentResults = [StatusCodes.Good, StatusCodes.BadOutOfRange],
InputArgumentDiagnosticInfos = [new DiagnosticInfo(), argumentDiagnostic],
OutputArguments = [Variant.From(42), Variant.From("assigned-version")]
};
CallMethodRequest? captured = null;
CancellationToken receivedToken = default;
m_session.Setup(s => s.CallAsync(
It.IsAny(), It.IsAny>(),
It.IsAny()))
.Returns, CancellationToken>((_, requests, token) =>
{
Assert.That(requests.Count, Is.EqualTo(1));
captured = requests[0];
receivedToken = token;
return new ValueTask(new CallResponse
{
ResponseHeader = new ResponseHeader(),
Results = [expected]
});
});
CallMethodResult actual = await m_client.CallWithResultAsync(
new ExpandedNodeId(91u, kNamespace), [Variant.From("input"), Variant.From(7u)],
cancellation.Token).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(actual.StatusCode, Is.EqualTo(status));
Assert.That(actual.InputArgumentResults,
Is.EqualTo(new StatusCode[] { StatusCodes.Good, StatusCodes.BadOutOfRange }));
Assert.That(actual.InputArgumentDiagnosticInfos[1].AdditionalInfo, Is.EqualTo("argument detail"));
Assert.That(actual.OutputArguments,
Is.EqualTo(new[] { Variant.From(42), Variant.From("assigned-version") }));
Assert.That(captured!.ObjectId, Is.EqualTo(new NodeId("object", 1)));
Assert.That(captured.MethodId, Is.EqualTo(new NodeId(91u, 1)));
Assert.That(captured.InputArguments, Is.EqualTo(new[] { Variant.From("input"), Variant.From(7u) }));
Assert.That(receivedToken, Is.EqualTo(cancellation.Token));
});
VerifyOneCallAndNoFallback();
}
[TestCase("missing-result")]
[TestCase("extra-result")]
[TestCase("extra-diagnostic")]
public async Task CallWithResultAsyncRejectsInvalidResponseCardinality(string shape)
{
var response = new CallResponse
{
ResponseHeader = new ResponseHeader(),
Results = [new CallMethodResult { StatusCode = StatusCodes.Good }]
};
if (shape == "missing-result")
{
response.Results = [];
}
else if (shape == "extra-result")
{
response.Results = [new CallMethodResult(), new CallMethodResult()];
}
else
{
response.DiagnosticInfos = [new DiagnosticInfo(), new DiagnosticInfo()];
}
SetupResponse(response);
ServiceResultException exception = (await Assert.ThrowsAsync(
async () => await m_client.CallWithResultAsync(
new ExpandedNodeId(91u, kNamespace), []).ConfigureAwait(false)))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
VerifyOneCallAndNoFallback();
}
[Test]
public async Task CallWithResultAsyncRejectsBadServiceStatusEvenWithGoodMethodResult()
{
SetupResponse(new CallResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadSessionClosed },
Results =
[new CallMethodResult { StatusCode = StatusCodes.Good, OutputArguments = [Variant.From(123)] }]
});
ServiceResultException exception = (await Assert.ThrowsAsync(
async () => await m_client.CallWithResultAsync(
new ExpandedNodeId(91u, kNamespace), []).ConfigureAwait(false)))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
VerifyOneCallAndNoFallback();
}
[Test]
public async Task CallWithResultAsyncPropagatesServiceExceptionWithoutRetry()
{
var primary = new ServiceResultException(StatusCodes.BadTimeout);
m_session.Setup(s => s.CallAsync(
It.IsAny(), It.IsAny>(),
It.IsAny()))
.Throws(primary);
ServiceResultException exception = (await Assert.ThrowsAsync(
async () => await m_client.CallWithResultAsync(
new ExpandedNodeId(91u, kNamespace), []).ConfigureAwait(false)))!;
Assert.That(exception, Is.SameAs(primary));
VerifyOneCallAndNoFallback();
}
[TestCase(false)]
[TestCase(true)]
public async Task CallWithResultAsyncRejectsNonLocalMethodWithoutDispatch(bool remote)
{
ExpandedNodeId method = remote
? new ExpandedNodeId(new NodeId(91u, 1), null, 3)
: new ExpandedNodeId(91u, "urn:missing:method");
int namespaceCount = m_context.NamespaceUris.Count;
ServiceResultException exception = (await Assert.ThrowsAsync(
async () => await m_client.CallWithResultAsync(method, []).ConfigureAwait(false)))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
Assert.That(m_context.NamespaceUris.Count, Is.EqualTo(namespaceCount));
m_session.Verify(s => s.CallAsync(
It.IsAny(), It.IsAny>(),
It.IsAny()), Times.Never);
}
[Test]
public async Task CallWithResultAsyncRejectsNullProxy()
{
ArgumentNullException exception = (await Assert.ThrowsAsync(
async () => await ObjectTypeClientExtensions.CallWithResultAsync(
null!, new ExpandedNodeId(91u, kNamespace), []).ConfigureAwait(false)))!;
Assert.That(exception.ParamName, Is.EqualTo("client"));
}
private void SetupResponse(CallResponse response)
{
m_session.Setup(s => s.CallAsync(
It.IsAny(), It.IsAny>(),
It.IsAny()))
.Returns(new ValueTask(response));
}
private void VerifyOneCallAndNoFallback()
{
m_session.Verify(s => s.CallAsync(
It.IsAny(), It.IsAny>(),
It.IsAny()), Times.Once);
m_session.Verify(s => s.TranslateBrowsePathsToNodeIdsAsync(
It.IsAny(), It.IsAny>(),
It.IsAny()), Times.Never);
}
private sealed class ResultProxy : ObjectTypeClient
{
public ResultProxy(ISessionClient session, NodeId id, ITelemetryContext telemetry)
: base(session, id, telemetry)
{
}
}
private const string kNamespace = "urn:test:result-method";
private static readonly StatusCode[] s_methodStatuses =
[
StatusCodes.Good, StatusCodes.GoodResultsMayBeIncomplete, StatusCodes.Uncertain,
StatusCodes.BadMethodInvalid, StatusCodes.BadInvalidArgument
];
private ServiceMessageContext m_context = null!;
private Mock m_session = null!;
private ResultProxy m_client = null!;
}
}