using ZB.MOM.WW.MxGateway.Client; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; /// /// Live meter legs for the 06/S-1 write-confirmation gate — the observation that closed the /// archreview finding: against a correlated gateway (mxaccessgw server ≥ b948e69, worker ≥ /// 53f69cd) every unary write reply carries the worker's OnWriteComplete outcome as /// statuses[0], so galaxy.writes.unconfirmed — which used to increment on EVERY /// write — stays flat, and only a completion-wait timeout (or a pre-correlation gateway) meters. /// /// ⚠️ Run these FILTERED (one test class), never as part of a full-assembly run with the env vars /// set: the offline exercise the unconfirmed path /// against fakes, and xunit runs test classes in parallel, so their increments would pollute the /// process-wide counters these tests listen to. /// /// [Trait("Category", "Integration")] public sealed class GatewayGalaxyWriteMeterLiveTests { private const string WriteRef = "TestMachine_002.TestFloat"; /// /// Confirmed writes keep both write meters flat: five committed writes and one refusal, all /// carrying a correlated status row — zero unconfirmed (the counter is presence-based, /// so the refusal must not increment it either) and zero advise_failed. /// [Fact] public async Task Live_confirmed_writes_keep_unconfirmed_meter_flat() { var (endpoint, apiKey) = RequireLiveGatewayOrSkip(); long unconfirmed = 0; long adviseFailed = 0; using var listener = new System.Diagnostics.Metrics.MeterListener(); listener.InstrumentPublished = (instrument, l) => { if (instrument.Name is "galaxy.writes.unconfirmed" or "galaxy.writes.advise_failed") l.EnableMeasurementEvents(instrument); }; listener.SetMeasurementEventCallback((instrument, value, _, _) => { if (instrument.Name == "galaxy.writes.unconfirmed") Interlocked.Add(ref unconfirmed, value); else Interlocked.Add(ref adviseFailed, value); }); listener.Start(); var ct = TestContext.Current.CancellationToken; await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Meter")); await session.ConnectAsync(BuildClientOptions(endpoint, apiKey), ct); var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); for (var i = 0; i < 5; i++) { var r = await writer.WriteAsync( [new WriteRequest(WriteRef, 7000.0f + i)], _ => SecurityClassification.FreeAccess, ct); Assert.Equal(0u, r[0].StatusCode); } var refused = await writer.WriteAsync( [new WriteRequest("TestMachine_002.Tagname", "meter-leg-refusal")], _ => SecurityClassification.FreeAccess, ct); Assert.Equal(2u, (refused[0].StatusCode >> 30) & 0x3u); listener.Dispose(); Assert.Equal(0, Interlocked.Read(ref unconfirmed)); Assert.Equal(0, Interlocked.Read(ref adviseFailed)); } /// /// Timeout-control leg — run ONLY while the target gateway has the completion wait disabled /// (MxGateway__Worker__WriteCompletionWaitMilliseconds=0, an NSSM env-var flip + /// service restart on a deployed instance): every reply is then empty-statuses, so the writer /// reports provisional Good and meters each write unconfirmed. Gated on /// MXGW_WRITE_WAIT_DISABLED=1 so it can never run (and fail) against a /// normally-configured gateway. Remove the override (same nssm command with -) and /// restart afterwards; /// passing again proves the restoration. /// [Fact] public async Task Live_wait_disabled_gateway_yields_provisional_good_and_metered_unconfirmed() { if (Environment.GetEnvironmentVariable("MXGW_WRITE_WAIT_DISABLED") != "1") Assert.Skip("Set MXGW_WRITE_WAIT_DISABLED=1 (with the gateway's completion wait set to 0) to run the timeout leg."); var (endpoint, apiKey) = RequireLiveGatewayOrSkip(); long unconfirmed = 0; using var listener = new System.Diagnostics.Metrics.MeterListener(); listener.InstrumentPublished = (instrument, l) => { if (instrument.Name == "galaxy.writes.unconfirmed") l.EnableMeasurementEvents(instrument); }; listener.SetMeasurementEventCallback((_, value, _, _) => Interlocked.Add(ref unconfirmed, value)); listener.Start(); var ct = TestContext.Current.CancellationToken; await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Leg3")); await session.ConnectAsync(BuildClientOptions(endpoint, apiKey), ct); var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); for (var i = 0; i < 3; i++) { var r = await writer.WriteAsync( [new WriteRequest(WriteRef, 7100.0f + i)], _ => SecurityClassification.FreeAccess, ct); Assert.Equal(0u, r[0].StatusCode); // provisional Good off the empty-statuses reply } listener.Dispose(); Assert.Equal(3, Interlocked.Read(ref unconfirmed)); } private static (string Endpoint, string ApiKey) RequireLiveGatewayOrSkip() { var endpoint = Environment.GetEnvironmentVariable("MXGW_ENDPOINT"); var apiKey = Environment.GetEnvironmentVariable("GALAXY_MXGW_API_KEY"); if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey)) Assert.Skip("Set MXGW_ENDPOINT + GALAXY_MXGW_API_KEY to run the live write-meter legs."); return (endpoint!, apiKey!); } private static MxGatewayClientOptions BuildClientOptions(string endpoint, string apiKey) => new() { Endpoint = new Uri(endpoint, UriKind.Absolute), ApiKey = apiKey, UseTls = false, ConnectTimeout = TimeSpan.FromSeconds(10), DefaultCallTimeout = TimeSpan.FromSeconds(30), StreamTimeout = TimeSpan.FromSeconds(30), }; }