using ZB.MOM.WW.MxGateway.Contracts.Proto; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; /// /// archreview 06/S-1: Galaxy writes must fail CLOSED. When AdviseSupervisory fails, the raw /// Write (which the file comment notes "doesn't throw but the value never reaches the galaxy") must /// NOT be issued — the writer returns BadCommunicationError so the #5 node-revert fires instead /// of leaving a phantom-Good node. These tests drive the real write pipeline through the internal /// seam (the SDK session types are sealed + internal-ctor and cannot be faked). /// public sealed class GatewayGalaxyDataWriterFailClosedTests { private static GatewayGalaxyDataWriter NewWriter() => new(new GalaxyMxSession(new Config.GalaxyMxAccessOptions(ClientName: "OtOpcUa-Test")), writeUserId: 0); // ---------------- T5: seam ---------------- /// A secured-write classification routes through WriteSecured and never advises (regression via the seam). [Fact] public async Task WriteOne_SecuredClassification_RoutesThroughWriteSecured_NoAdvise() { var ops = new FakeMxWriteOps(); var writer = NewWriter(); var result = await writer.WriteOneForTestAsync( ops, new WriteRequest("Tag.Sec", true), SecurityClassification.SecuredWrite, CancellationToken.None); result.StatusCode.ShouldBe(StatusCodeMap.Good); ops.Invokes.Count(r => r.Command.Kind == MxCommandKind.WriteSecured).ShouldBe(1); ops.Invokes.Any(r => r.Command.Kind == MxCommandKind.AdviseSupervisory).ShouldBeFalse(); ops.RawWriteHandles.ShouldBeEmpty(); } // ---------------- T6: fail-closed on advise failure ---------------- /// When AdviseSupervisory fails (non-OK protocol status), the raw Write must NOT be issued and /// the writer returns Bad (which fires the #5 node revert). The handle must not linger in the supervised /// cache so the next write retries the advise. [Fact] public async Task WriteOne_AdviseFails_ReturnsBadCommunicationError_AndNeverIssuesRawWrite() { var ops = new FakeMxWriteOps { InvokeResponder = _ => new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = "advise boom" }, }, }; var writer = NewWriter(); var result = await writer.WriteOneForTestAsync( ops, new WriteRequest("Tag.A", true), SecurityClassification.FreeAccess, CancellationToken.None); result.StatusCode.ShouldBe(StatusCodeMap.BadCommunicationError); ops.Invokes.Count(r => r.Command.Kind == MxCommandKind.AdviseSupervisory).ShouldBe(1); ops.RawWriteHandles.ShouldBeEmpty(); // the knowingly-lost raw write was NOT issued writer.CachedSupervisedHandleCount.ShouldBe(0); // advise failure forgot the handle → next write retries } /// When advise succeeds, the raw Write proceeds and a clean reply returns Good (regression). [Fact] public async Task WriteOne_AdviseSucceeds_RawWriteProceeds() { var ops = new FakeMxWriteOps { InvokeResponder = _ => new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }, }; var writer = NewWriter(); var result = await writer.WriteOneForTestAsync( ops, new WriteRequest("Tag.B", 42), SecurityClassification.FreeAccess, CancellationToken.None); result.StatusCode.ShouldBe(StatusCodeMap.Good); ops.Invokes.Count(r => r.Command.Kind == MxCommandKind.AdviseSupervisory).ShouldBe(1); ops.RawWriteHandles.ShouldHaveSingleItem(); // the raw write was issued after the successful advise writer.CachedSupervisedHandleCount.ShouldBe(1); } // ---------------- fakes ---------------- /// A fake that records every call and returns canned replies. private sealed class FakeMxWriteOps : IMxWriteOps { public string SessionId => "sess-1"; public int ServerHandle => 100; public int NextHandle { get; init; } = 7; public List AddItemRefs { get; } = new(); public List Invokes { get; } = new(); public List RawWriteHandles { get; } = new(); /// Responder for ; null ⇒ an OK (empty) reply. public Func? InvokeResponder { get; init; } /// Responder for ; null ⇒ an empty (Good) reply. public Func? RawWriteResponder { get; init; } public Task AddItemAsync(string fullRef, CancellationToken ct) { AddItemRefs.Add(fullRef); return Task.FromResult(NextHandle); } public Task InvokeAsync(MxCommandRequest request, CancellationToken ct) { Invokes.Add(request); return Task.FromResult(InvokeResponder?.Invoke(request) ?? new MxCommandReply()); } public Task WriteRawAsync(int itemHandle, MxValue value, int userId, CancellationToken ct) { RawWriteHandles.Add(itemHandle); return Task.FromResult(RawWriteResponder?.Invoke() ?? new MxCommandReply()); } } }