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