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); } // ---------------- T7: counters ---------------- /// A fail-closed advise increments galaxy.writes.advise_failed (and not unconfirmed). [Fact] public async Task WriteOne_AdviseFails_Increments_AdviseFailed_Meter() { using var advise = new MeterRecorder("galaxy.writes.advise_failed"); using var unconfirmed = new MeterRecorder("galaxy.writes.unconfirmed"); var ops = new FakeMxWriteOps { InvokeResponder = _ => new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure } }, }; var writer = NewWriter(); await writer.WriteOneForTestAsync(ops, new WriteRequest("Tag.A", true), SecurityClassification.FreeAccess, CancellationToken.None); advise.Total.ShouldBe(1); unconfirmed.Total.ShouldBe(0); } /// An advised write whose reply carries an EMPTY statuses array returns Good but increments /// galaxy.writes.unconfirmed (the commit is unconfirmed pending gateway WriteComplete correlation). [Fact] public async Task WriteOne_EmptyStatuses_ReturnsGood_And_Increments_Unconfirmed_Meter() { using var advise = new MeterRecorder("galaxy.writes.advise_failed"); using var unconfirmed = new MeterRecorder("galaxy.writes.unconfirmed"); var ops = new FakeMxWriteOps { InvokeResponder = _ => new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }, RawWriteResponder = () => new MxCommandReply(), // no ProtocolStatus, EMPTY statuses ⇒ Good }; var writer = NewWriter(); var result = await writer.WriteOneForTestAsync(ops, new WriteRequest("Tag.B", 1), SecurityClassification.FreeAccess, CancellationToken.None); result.StatusCode.ShouldBe(StatusCodeMap.Good); unconfirmed.Total.ShouldBe(1); advise.Total.ShouldBe(0); } /// A reply carrying an MX status ROW increments neither meter (not empty-statuses, advise OK). [Fact] public async Task WriteOne_StatusRowReply_Increments_Neither_Meter() { using var advise = new MeterRecorder("galaxy.writes.advise_failed"); using var unconfirmed = new MeterRecorder("galaxy.writes.unconfirmed"); var okStatusReply = new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }; okStatusReply.Statuses.Add(new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok }); var ops = new FakeMxWriteOps { InvokeResponder = _ => new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }, RawWriteResponder = () => okStatusReply, }; var writer = NewWriter(); var result = await writer.WriteOneForTestAsync(ops, new WriteRequest("Tag.C", 2), SecurityClassification.FreeAccess, CancellationToken.None); result.StatusCode.ShouldBe(StatusCodeMap.Good); // Success/Ok status row ⇒ Good advise.Total.ShouldBe(0); unconfirmed.Total.ShouldBe(0); // NOT empty-statuses ⇒ not metered unconfirmed } // ---------------- fakes ---------------- /// Listens to a single instrument by name on the Galaxy driver meter and sums the values. private sealed class MeterRecorder : IDisposable { private readonly string _name; private readonly System.Diagnostics.Metrics.MeterListener _listener; private long _total; private readonly object _gate = new(); public MeterRecorder(string instrumentName) { _name = instrumentName; _listener = new System.Diagnostics.Metrics.MeterListener { InstrumentPublished = (instrument, listener) => { if (instrument.Meter.Name == EventPump.MeterName && instrument.Name == _name) listener.EnableMeasurementEvents(instrument); } }; _listener.SetMeasurementEventCallback((_, value, _, _) => { lock (_gate) _total += value; }); _listener.Start(); } public long Total { get { lock (_gate) return _total; } } public void Dispose() => _listener.Dispose(); } /// 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()); } } }