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; /// /// Write-outcome self-correction — the BEHAVIOURAL half of the failed-device-write surfacing /// (the pure decision tables live in ). Boots a real /// through (the same harness /// uses) so the continuation runs against a live node /// manager (real Lock + SystemContext + Server), and drives /// + /// directly: /// /// Item B (Bad-quality blip + settle): a FAILED outcome on a node still holding the /// optimistic value settles the node back to its prior value/status; SUCCESS / a moved-on poll /// leave the node untouched. /// Item C (AuditWriteUpdateEvent): the built audit event-state carries SourceNode=node, /// AttributeId=Value, OldValue=prior, NewValue=attempted, ClientUserId=writer, Status=false, and /// the device Reason in its Message. (The end-to-end Server.ReportEvent dispatch needs a /// subscribed event monitored-item to observe — the nearest deterministic seam is the builder.) /// /// /// Coverage boundary (deliberate): Item C asserts the audit-event builder in isolation, /// not its single call-site wiring inside /// (line ~1107). Observing that wiring end-to-end would require a subscribed event monitored-item; /// the one in-lock builder call is covered by inspection + the production-proven path (shipped /// bb59fd4e). If a second audit call-site is ever added, promote this to an observed-event test. /// /// public sealed class NodeManagerWriteRevertTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-write-revert-{Guid.NewGuid():N}"); // ───────────────────────────── Item B — Bad-quality blip + settle ───────────────────────────── /// (B1) A FAILED device-write outcome on a node that still holds the optimistic value SETTLES the /// node back to its captured prior value + prior status (the transient Bad blip coalesces within one /// publishing interval — see the method remarks — so we assert the observable settled state). [Fact] public async Task Failed_outcome_settles_node_back_to_prior_value_and_status() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); // Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject). nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); var node = nm.TryGetVariable("eq-1/sp")!; node.Value = 42; // simulate the SDK's optimistic apply of the rejected write nm.RevertOptimisticWriteIfNeeded( "eq-1/sp", outcome: new NodeWriteOutcome(false, "device rejected register 42"), optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); node.Value.ShouldBe(7); // settled back to prior value node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // settled back to prior status (NOT stuck Bad) await host.DisposeAsync(); } /// (B2) A SUCCESS outcome NEVER reverts — the optimistic value stands (the next poll re-confirms /// it via the normal WriteValue path). [Fact] public async Task Successful_outcome_leaves_optimistic_value() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); var node = nm.TryGetVariable("eq-1/sp")!; node.Value = 42; nm.RevertOptimisticWriteIfNeeded( "eq-1/sp", outcome: new NodeWriteOutcome(true, null), optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); node.Value.ShouldBe(42); // success ⇒ optimistic value stands await host.DisposeAsync(); } /// (B3) A failed outcome where a fresh driver poll already moved the node OFF the optimistic value /// does NOT clobber the poll — the node keeps the fresh polled value (the still-holds-optimistic guard). [Fact] public async Task Failed_outcome_does_not_clobber_a_fresh_poll() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); var node = nm.TryGetVariable("eq-1/sp")!; node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42) nm.RevertOptimisticWriteIfNeeded( "eq-1/sp", outcome: new NodeWriteOutcome(false, "device rejected"), optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); node.Value.ShouldBe(99); // poll value preserved — not reverted to 7 await host.DisposeAsync(); } /// (B4) An unknown node id (rebuilt/removed in the interim) is a no-op — the continuation must not /// throw out of its fire-and-forget body. [Fact] public async Task Failed_outcome_on_missing_node_is_a_noop() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; Should.NotThrow(() => nm.RevertOptimisticWriteIfNeeded( "eq-1/gone", outcome: new NodeWriteOutcome(false, "device rejected"), optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op")); await host.DisposeAsync(); } // ───────────────────────────── Item C — AuditWriteUpdateEvent builder ───────────────────────────── /// (C1) The built audit event reflects the rejected write: SourceNode = the node, AttributeId = /// Value, OldValue = prior, NewValue = attempted, ClientUserId = the writer, the AuditEvent Status boolean /// = false (failed), and the device Reason is carried in the Message. [Fact] public async Task Built_audit_event_reflects_the_failed_write() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); var node = nm.TryGetVariable("eq-1/sp")!; var audit = nm.BuildWriteFailureAuditEvent( node, outcome: new NodeWriteOutcome(false, "FC06 rejected"), optimisticValue: 42, priorValue: 7, clientUserId: "op"); audit.ShouldNotBeNull(); audit.SourceNode.Value.ShouldBe(node.NodeId); audit.AttributeId.Value.ShouldBe((uint)Attributes.Value); audit.OldValue.Value.ShouldBe(7); audit.NewValue.Value.ShouldBe(42); audit.ClientUserId.Value.ShouldBe("op"); audit.Status.Value.ShouldBeFalse(); // AuditEvent Status = false ⇒ the write failed audit.Message.Value.Text.ShouldContain("FC06 rejected"); // device Reason carried in the Message await host.DisposeAsync(); } /// (C2) When the device gives no Reason, the audit Message still carries a sensible default /// ("device write rejected") rather than an empty/null message. [Fact] public async Task Built_audit_event_uses_default_reason_when_none_supplied() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); var node = nm.TryGetVariable("eq-1/sp")!; var audit = nm.BuildWriteFailureAuditEvent( node, outcome: new NodeWriteOutcome(false, null), optimisticValue: 42, priorValue: 7, clientUserId: "op"); audit.Message.Value.Text.ShouldContain("device write rejected"); await host.DisposeAsync(); } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.WriteRevertTest", ApplicationUri = $"urn:OtOpcUa.WriteRevertTest:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance); var server = new OtOpcUaSdkServer(); await host.StartAsync(server, Ct); return (host, server); } private static int AllocateFreePort() { using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); listener.Start(); var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }