using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
///
/// Review 01 L3 / H5 / H4 — the device-write dispatch behind
/// and the manager's clock: a gateway that throws synchronously is refused inline (the handler runs under
/// the SDK Lock, and the throw used to escape it), a faulted dispatch task is observed and still
/// reverts the optimistic value, and the timestamps the manager stamps come from the injected
/// .
///
public sealed class NodeManagerWriteDispatchTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dispatch-{Guid.NewGuid():N}");
/// H5 — a synchronous throw out of the gateway is a Bad result, never an escaped exception.
[Fact]
public async Task A_gateway_that_throws_synchronously_is_refused_inline()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var result = nm.DispatchDeviceWrite(
new ThrowingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns,
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
ServiceResult.IsBad(result).ShouldBeTrue();
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadDeviceFailure);
result.ToString().ShouldContain("boom");
nm.TryGetVariable("eq-1/sp")!.Value.ShouldBe(7, "nothing was applied, so nothing needs reverting");
}
finally { await host.DisposeAsync(); }
}
/// L3 — a faulted dispatch task is observed and the optimistic value is reverted with its reason.
[Fact]
public async Task A_faulted_dispatch_is_observed_and_reverts_the_optimistic_value()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
var result = nm.DispatchDeviceWrite(
new FaultingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns,
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
ServiceResult.IsGood(result).ShouldBeTrue("the dispatch itself started; the SDK applies the value optimistically");
node.Value = 42; // what the SDK does on Good
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && !Equals(node.Value, 7)) await Task.Delay(20, Ct);
node.Value.ShouldBe(7, "the faulted dispatch must revert the optimistic value");
node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
}
finally { await host.DisposeAsync(); }
}
/// Positive control for the two above: a gateway that succeeds leaves the optimistic value standing.
[Fact]
public async Task A_successful_dispatch_leaves_the_optimistic_value()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
nm.DispatchDeviceWrite(
new SucceedingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns,
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
node.Value = 42;
await Task.Delay(200, Ct);
node.Value.ShouldBe(42);
}
finally { await host.DisposeAsync(); }
}
/// H4 — the revert stamps the manager's clock, not the wall clock.
[Fact]
public async Task The_revert_timestamps_come_from_the_injected_clock()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var fixedNow = new DateTime(2030, 1, 2, 3, 4, 5, DateTimeKind.Utc);
nm.Clock = new FixedClock(fixedNow);
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 42;
nm.RevertOptimisticWriteIfNeeded(
"eq-1/sp", new NodeWriteOutcome(false, "rejected"), optimisticValue: 42, priorValue: 7,
priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns);
node.Timestamp.ShouldBe(fixedNow);
OtOpcUaNodeManager.RemainingShelveMs(fixedNow.AddSeconds(30), fixedNow).ShouldBe(30_000);
}
finally { await host.DisposeAsync(); }
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.DispatchTest",
ApplicationUri = $"urn:OtOpcUa.DispatchTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger.Instance),
() => new OtOpcUaSdkServer(), Ct);
public void Dispose()
{
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
}
private sealed class ThrowingGateway : IOpcUaNodeWriteGateway
{
public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
throw new InvalidOperationException("boom");
}
private sealed class FaultingGateway : IOpcUaNodeWriteGateway
{
public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromException(new TimeoutException("gateway ask timed out"));
}
private sealed class SucceedingGateway : IOpcUaNodeWriteGateway
{
public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromResult(new NodeWriteOutcome(true, null));
}
private sealed class FixedClock(DateTime utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(utcNow, TimeSpan.Zero);
}
}