using ZB.MOM.WW.MxGateway.Client; 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.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; /// /// Live-gateway smokes for the two seams that cannot be faked because the MXAccess /// session types are sealed with internal ctors — so the unit suite can only exercise them in /// isolation (see ): /// /// /// /// Reopen — the exact primitive sequence GalaxyDriver.ReopenAsync runs on a /// reconnect ( then /// ): a write seeds the /// item-handle + supervisory-advise caches, the recreate tears the session down and /// rebuilds it against the live gateway, the caches are dropped, and a follow-up write /// re-AddItems + re-AdviseSupervisory against the fresh session and succeeds. /// /// /// /// /// Write commit + persist — a no-login (WriteUserId = 0) write commits to /// the galaxy only because the writer AdviseSupervisory's the handle first; proven /// by reading the value back through a brand-new session (so the value came from /// the galaxy, not a client-side echo). /// /// /// /// /// Skip-gated: runs only when MXGW_ENDPOINT + GALAXY_MXGW_API_KEY point at a /// reachable gateway (so CI stays green). Captured 2026-06-14 against 10.100.0.48:5120. /// Writes only the dedicated test attribute TestMachine_002.TestFloat. /// /// [Trait("Category", "Integration")] public sealed class GatewayGalaxyLiveReopenAndWriteTests { /// The dedicated writable Galaxy test attribute (Float, Operate) used by both smokes. private const string WriteRef = "TestMachine_002.TestFloat"; [Fact] public async Task Live_reopen_recreates_session_and_re_establishes_write_handles() { var (endpoint, apiKey) = RequireLiveGatewayOrSkip(); var ct = TestContext.Current.CancellationToken; var clientOptions = BuildClientOptions(endpoint, apiKey); await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaReopenSmoke")); await session.ConnectAsync(clientOptions, ct); var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); // First write seeds both caches (AddItem -> item handle, AdviseSupervisory -> supervised handle). var first = await writer.WriteAsync( [new WriteRequest(WriteRef, 4242.0f)], _ => SecurityClassification.FreeAccess, ct); first.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "the first live write should return Good (0)"); writer.CachedItemHandleCount.ShouldBe(1, "the write should have cached one item handle"); writer.CachedSupervisedHandleCount.ShouldBe(1, "the write should have supervisory-advised the handle once"); // Exactly what GalaxyDriver.ReopenAsync does on a reconnect: recreate the live session, drop the caches. await session.RecreateAsync(clientOptions, ct); writer.InvalidateHandleCaches(); writer.CachedItemHandleCount.ShouldBe(0, "reopen must drop the stale item-handle cache"); writer.CachedSupervisedHandleCount.ShouldBe(0, "reopen must drop the stale supervisory-advise cache"); // A write after the reopen must re-AddItem + re-AdviseSupervisory against the fresh session and succeed — // proving RecreateAsync produced a working session and the writer rebuilt its handles. var second = await writer.WriteAsync( [new WriteRequest(WriteRef, 1313.0f)], _ => SecurityClassification.FreeAccess, ct); second.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "the post-reopen write should return Good (0)"); writer.CachedItemHandleCount.ShouldBe(1, "the post-reopen write should re-establish the item handle"); writer.CachedSupervisedHandleCount.ShouldBe(1, "the post-reopen write should re-advise the handle"); TestContext.Current.SendDiagnosticMessage( $"reopen smoke: wrote {WriteRef}=4242 -> recreate+invalidate -> wrote 1313, both Good, handles re-established"); } [Fact] public async Task Live_supervisory_write_commits_and_persists_to_galaxy() { var (endpoint, apiKey) = RequireLiveGatewayOrSkip(); var ct = TestContext.Current.CancellationToken; var clientOptions = BuildClientOptions(endpoint, apiKey); // Baseline: read the current value through a fresh session so we can pick a target that // genuinely CHANGES it (otherwise a no-op write could falsely "persist"). var current = await ReadbackFloatAsync(clientOptions, "OtOpcUaReadSmokeA", WriteRef, TimeSpan.FromSeconds(20), ct); var target = current is { } c && Math.Abs(c - 4242.0) < 1.0 ? 1313.0 : 4242.0; // Write via the production writer: no-login write that only commits because the writer // AdviseSupervisory's the handle first. await using (var writerSession = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaWriteSmoke"))) { await writerSession.ConnectAsync(clientOptions, ct); var writer = new GatewayGalaxyDataWriter(writerSession, writeUserId: 0); var results = await writer.WriteAsync( [new WriteRequest(WriteRef, (float)target)], _ => SecurityClassification.FreeAccess, ct); results.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "the supervisory write should return Good (0)"); } // Persist proof: a BRAND-NEW session must read the written value back from the galaxy. // Poll (the worker's commit is async) — fail with the last observed value if it never lands. double? persisted = null; var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(25); while (DateTime.UtcNow < deadline) { persisted = await ReadbackFloatAsync(clientOptions, "OtOpcUaReadSmokeB", WriteRef, TimeSpan.FromSeconds(15), ct); if (persisted is { } p && Math.Abs(p - target) < 0.5) break; await Task.Delay(1000, ct); } persisted.ShouldNotBeNull("a fresh session should have read a value for the test attribute"); persisted!.Value.ShouldBe(target, 0.5, $"the supervisory write must persist in the galaxy (fresh-session read-back; last observed {persisted})"); TestContext.Current.SendDiagnosticMessage( $"write-persist smoke: {WriteRef} {current} -> wrote {target} -> fresh-session read-back {persisted}"); } /// /// Write-while-subscribed — the writer must commit a no-login supervisory write to a /// tag that is ALREADY live-subscribed on the same session, via its own AddItem'd handle. /// This replaces the retired borrow smoke: the borrow optimization's premise ("a subscription /// handle is usable for a committing no-login write") was disproven by the correlated write /// replies — a plain-advised subscription handle refuses the write (SECURITY_ERROR detail /// 1008) and refuses an advise upgrade (E_INVALIDARG). What this pins instead is the fix's /// premise: MXAccess's one-advise-per-handle limit is per HANDLE, not per attribute, so the /// writer's own supervisory-advised handle commits while the subscription keeps its own. /// [Fact] public async Task Live_write_to_subscribed_tag_commits_via_own_handle() { var (endpoint, apiKey) = RequireLiveGatewayOrSkip(); var ct = TestContext.Current.CancellationToken; var clientOptions = BuildClientOptions(endpoint, apiKey); await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaSubWriteSmoke")); await session.ConnectAsync(clientOptions, ct); // Live subscription on the tag — the state the borrow path used to piggyback on. var subscriber = new GatewayGalaxySubscriber(session); var subResults = await subscriber.SubscribeBulkAsync([WriteRef], bufferedUpdateIntervalMs: 0, ct); var match = subResults.SingleOrDefault(r => string.Equals(r.TagAddress, WriteRef, StringComparison.OrdinalIgnoreCase) && r.WasSuccessful); match.ShouldNotBeNull($"the gateway should accept a subscription on {WriteRef}"); // The production writer must AddItem its own handle and commit, despite the live subscription. var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); var result = await writer.WriteAsync( [new WriteRequest(WriteRef, 2727.0f)], _ => SecurityClassification.FreeAccess, ct); result.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "a write to an already-subscribed tag must commit Good via the writer's own handle"); writer.AddItemCallCount.ShouldBe(1, "the writer owns its handle — exactly one AddItem"); writer.CachedSupervisedHandleCount.ShouldBe(1, "the writer's own handle should be supervisory-advised"); TestContext.Current.SendDiagnosticMessage( $"write-while-subscribed smoke: subscribed {WriteRef} (handle {match!.ItemHandle}); own-handle write Good, AddItemCallCount=1"); } /// /// archreview 06/S-1 guard — after the fail-closed advise change, a NORMAL advised no-login write /// must STILL return Good (0). Guards against over-eager fail-closing: the advise round-trip succeeds /// on a healthy gateway, so the raw Write proceeds and commits. If this ever returns Bad on a healthy /// gateway, the fail-closed branch is mis-judging a successful advise. Skip-gated like the others. /// [Fact] public async Task Live_normal_advised_write_still_returns_good_under_failclosed_advise() { var (endpoint, apiKey) = RequireLiveGatewayOrSkip(); var ct = TestContext.Current.CancellationToken; var clientOptions = BuildClientOptions(endpoint, apiKey); await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaFailClosedGuard")); await session.ConnectAsync(clientOptions, ct); var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); var result = await writer.WriteAsync( [new WriteRequest(WriteRef, 5151.0f)], _ => SecurityClassification.FreeAccess, ct); result.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "a normal advised write must still return Good after the fail-closed advise change (no over-eager fail-close)"); writer.CachedSupervisedHandleCount.ShouldBe(1, "the healthy advise should have supervised the handle"); TestContext.Current.SendDiagnosticMessage( $"fail-closed guard smoke: normal advised write {WriteRef}=5151 returned Good; handle supervised"); } /// /// 06/S-1 confirmation smoke — the gateway (mxaccessgw ≥ 53f69cd) correlates the worker's /// OnWriteComplete onto the unary write reply as statuses[0], so the production /// writer's verdict is now the real COM commit outcome, not worker-side acceptance: /// /// a committed write returns Good off a CONFIRMED status row /// (success:-1, category:OK) — pre-correlation this was a provisional Good off an /// empty statuses array; /// a REFUSED write (read-only attribute) returns a Bad-family code off /// the correlated failure row (live shape: category:OPERATIONAL_ERROR, detail:1007) /// — the capability that never existed before: pre-correlation the refusal was invisible /// and reported Good, so the #5 write-outcome self-correction never fired for Galaxy. /// /// /// Requires both deployed gateway halves at the correlation build (server ≥ b948e69, worker ≥ /// 53f69cd); against an older gateway the refusal leg fails — that is the point of the gate. /// [Fact] public async Task Live_correlated_write_statuses_confirm_commit_and_surface_refusal() { var (endpoint, apiKey) = RequireLiveGatewayOrSkip(); var ct = TestContext.Current.CancellationToken; var clientOptions = BuildClientOptions(endpoint, apiKey); await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Smoke")); await session.ConnectAsync(clientOptions, ct); var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); // Happy path: the confirmed commit comes back Good (statuses[0] success row, not empty-array // provisional). The commit itself is proven by Live_supervisory_write_commits_and_persists_to_galaxy. var good = await writer.WriteAsync( [new WriteRequest(WriteRef, 6262.0f)], _ => SecurityClassification.FreeAccess, ct); good.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "a committed write should return Good off the correlated status row"); // Refusal path: Tagname is read-only, so the automation object refuses the write and the // correlated failure row must surface as a Bad-family code (top two bits 10b). Pre-correlation // this returned Good. Assert the family, not the exact code — the detail→code mapping may // gain per-code specificity (StatusCodeMap.FromCategory) without invalidating the gate. var refused = await writer.WriteAsync( [new WriteRequest("TestMachine_002.Tagname", "refused-write-probe")], _ => SecurityClassification.FreeAccess, ct); var refusedCode = refused.ShouldHaveSingleItem().StatusCode; ((refusedCode >> 30) & 0x3u).ShouldBe(2u, $"a refused write must surface a Bad-family code off the correlated failure row (got 0x{refusedCode:X8})"); TestContext.Current.SendDiagnosticMessage( $"06/S-1 smoke: {WriteRef}=6262 -> Good confirmed; Tagname write refused with 0x{refusedCode:X8}"); } 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 gateway reopen/write smokes."); 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), }; /// /// Open a fresh session, subscribe to a single reference, and return the first decoded /// data-change value (the subscribe snapshot reflects the galaxy's current value), or null /// on timeout. A fresh session per call guarantees the value came from the galaxy. /// private static async Task ReadbackFloatAsync( MxGatewayClientOptions clientOptions, string clientName, string fullRef, TimeSpan timeout, CancellationToken ct) { await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: clientName)); await session.ConnectAsync(clientOptions, ct); var subscriber = new GatewayGalaxySubscriber(session); // First decoded data-change value, completed by the pump (an explicit TCS rather than a // captured local, so the value's visibility across the Task boundary is unambiguous). var firstValue = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var streamCts = CancellationTokenSource.CreateLinkedTokenSource(ct); streamCts.CancelAfter(timeout); var pump = Task.Run(async () => { try { await foreach (var ev in subscriber.StreamEventsAsync(streamCts.Token)) { // SubscribeBulk uses Advise (not AdviseBuffered), so the initial galaxy-value // snapshot arrives as OnDataChange — never OnBufferedDataChange. if (ev.Family != MxEventFamily.OnDataChange) continue; if (MxValueDecoder.Decode(ev.Value) is not { } decoded) continue; firstValue.TrySetResult(Convert.ToDouble(decoded)); return; } } catch (OperationCanceledException) { /* timeout, or stream ended */ } firstValue.TrySetResult(null); // timed out / stream completed without a data event }, ct); // Let the StreamEvents RPC attach before adding the subscription so the initial snapshot isn't // missed. bufferedUpdateIntervalMs:0 skips the superfluous SetBufferedUpdateInterval RPC — the // Advise-based snapshot is delivered regardless. await Task.Delay(250, ct); await subscriber.SubscribeBulkAsync([fullRef], bufferedUpdateIntervalMs: 0, ct); var result = await firstValue.Task; streamCts.Cancel(); // stop the pump's StreamEvents enumeration await pump; // observe any pump exception + ensure teardown before the session disposes return result; } }