diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs index 2b4b0aa..16e53c9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs @@ -897,6 +897,9 @@ public sealed class MxAccessCommandExecutorTests FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + // No completion source in this test — disable the bounded reply wait + // so the forwarding assertions don't pay the default 1.5 s timeout. + session.WriteCompletionTimeout = TimeSpan.Zero; await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand( @@ -919,6 +922,8 @@ public sealed class MxAccessCommandExecutorTests FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + // Same rationale as the WriteSecured forwarding test above. + session.WriteCompletionTimeout = TimeSpan.Zero; await session.StartAsync(workerProcessId: 1234); DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc); @@ -934,6 +939,181 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal(44, fakeComObject.WriteVerifierUserId); } + /// + /// Verifies the fast-completion ordering edge: a completion recorded while + /// the WriteSecured COM call is still on the stack (MXAccess committing + /// synchronously) is newer than the pre-call baseline and lands on the + /// reply — the wait never misses a callback that beat it. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesDuringComCall_ReturnsStatuses() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 82); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + fakeComObject.OnWriteSecuredCallback = () => + sink.WriteCompletionCache.Record(82, 820, CreateCompletionRows(detail: 4321)); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand( + "write-secured-fast", serverHandle: 82, itemHandle: 820, value: 1, currentUserId: 11, verifierUserId: 22)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.True(reply.HasHresult); + Assert.Equal(0, reply.Hresult); + MxStatusProxy row = Assert.Single(reply.Statuses); + Assert.Equal(4321, row.Detail); + Assert.Equal(MxStatusCategory.Ok, row.Category); + } + + /// + /// Verifies the pump-wait path: the completion arrives after the COM call + /// returned, while the executor is pump-waiting, and still lands on the + /// reply. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesWhileWaiting_ReturnsStatuses() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 83); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + session.WriteCompletionTimeout = TimeSpan.FromSeconds(10); + await session.StartAsync(workerProcessId: 1234); + + Task pending = session.DispatchAsync(CreateWriteSecuredCommand( + "write-secured-waiting", serverHandle: 83, itemHandle: 830, value: 1, currentUserId: 11, verifierUserId: 22)); + await Task.Delay(50); + sink.WriteCompletionCache.Record(83, 830, CreateCompletionRows(detail: 99)); + + MxCommandReply reply = await pending; + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(99, Assert.Single(reply.Statuses).Detail); + } + + /// + /// Verifies the timeout fallback: no completion within the bounded wait + /// returns today's reply shape — protocol OK with EMPTY statuses (the + /// consumer's honest-unconfirmed path), never a synthesized failure row. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_WriteSecured_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 84); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand( + "write-secured-timeout", serverHandle: 84, itemHandle: 840, value: 1, currentUserId: 11, verifierUserId: 22)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.True(reply.HasHresult); + Assert.Equal(0, reply.Hresult); + Assert.Empty(reply.Statuses); + } + + /// + /// Verifies the version-baseline rule end to end: a completion recorded + /// BEFORE the write was dispatched is stale and must not be misattributed + /// to this write — the reply times out empty instead. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_WriteSecured_IgnoresStaleCompletionFromBeforeTheCall() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 85); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + sink.WriteCompletionCache.Record(85, 850, CreateCompletionRows(detail: 1111)); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand( + "write-secured-stale", serverHandle: 85, itemHandle: 850, value: 1, currentUserId: 11, verifierUserId: 22)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Empty(reply.Statuses); + } + + /// + /// Verifies that WriteSecured2 correlates the same way as WriteSecured + /// (fast-completion edge). + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_WriteSecured2_WhenCompletionArrivesDuringComCall_ReturnsStatuses() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 86); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + fakeComObject.OnWriteSecuredCallback = () => + sink.WriteCompletionCache.Record(86, 860, CreateCompletionRows(detail: 2222)); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2Command( + "write-secured2-fast", serverHandle: 86, itemHandle: 860, value: 1, + timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), currentUserId: 33, verifierUserId: 44)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(2222, Assert.Single(reply.Statuses).Detail); + } + + /// + /// Verifies plain Write stays fire-and-forget: even with a huge completion + /// timeout configured and no completion source, the reply returns + /// immediately (guarded well under the configured wait) with empty + /// statuses — only the secured write kinds enter the bounded wait. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_Write_DoesNotWaitForCompletion() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 87); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + session.WriteCompletionTimeout = TimeSpan.FromSeconds(30); + await session.StartAsync(workerProcessId: 1234); + + Task pending = session.DispatchAsync(CreateWriteCommand( + "plain-write-no-wait", serverHandle: 87, itemHandle: 870, value: 1, userId: 5)); + Task completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(5))); + + Assert.Same(pending, completed); + MxCommandReply reply = await pending; + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Empty(reply.Statuses); + } + + private static Google.Protobuf.Collections.RepeatedField CreateCompletionRows(int detail) + { + return new Google.Protobuf.Collections.RepeatedField + { + new MxStatusProxy + { + Success = 1, + Category = MxStatusCategory.Ok, + Detail = detail, + }, + }; + } + /// Verifies that Write without a payload returns an invalid request error. /// A task that represents the asynchronous operation. [Fact] @@ -1775,6 +1955,28 @@ public sealed class MxAccessCommandExecutorTests TimeSpan.FromMilliseconds(25)); } + /// + /// Test sink that owns a real write-completion cache without touching the + /// MXAccess COM RCW (Attach is a no-op). Implements the provider seam so + /// shares this cache with the write + /// executor, letting tests record completions the executor's bounded + /// wait then observes. + /// + private sealed class CompletionCacheEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider + { + public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new MxAccessWriteCompletionCache(); + + public void Attach( + object mxAccessComObject, + string sessionId) + { + } + + public void Detach() + { + } + } + private sealed class FakeMxAccessComObject : IMxAccessServer { private readonly int registerHandle; @@ -1790,6 +1992,14 @@ public sealed class MxAccessCommandExecutorTests private readonly IReadOnlyDictionary writeExceptionByItemHandle; private readonly List operationNames = new(); + /// + /// Invoked at the end of a successful WriteSecured/WriteSecured2 — + /// stands in for MXAccess committing synchronously and delivering + /// OnWriteComplete while the COM call is still on the stack, so + /// tests can exercise the fast-completion ordering edge. + /// + public Action? OnWriteSecuredCallback { get; set; } + /// Initializes a fake MXAccess COM object with the given handles and optional exceptions. /// Return value for Register method. /// Return value for AddItem method. @@ -2100,6 +2310,7 @@ public sealed class MxAccessCommandExecutorTests WriteValue = value; WriteThreadId = Environment.CurrentManagedThreadId; ThrowIfWriteFailureConfigured(itemHandle); + OnWriteSecuredCallback?.Invoke(); } /// @@ -2120,6 +2331,7 @@ public sealed class MxAccessCommandExecutorTests WriteTimestamp = timestamp; WriteThreadId = Environment.CurrentManagedThreadId; ThrowIfWriteFailureConfigured(itemHandle); + OnWriteSecuredCallback?.Invoke(); } private void ThrowIfWriteFailureConfigured(int itemHandle)