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