using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; using Google.Protobuf.WellKnownTypes; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.MxAccess; using ZB.MOM.WW.MxGateway.Worker.Sta; using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessCommandExecutorTests { /// Verifies that Register command calls MXAccess on the STA thread and preserves the server handle. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Register_CallsMxAccessOnStaAndPreservesServerHandle() { FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 42)); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(CreateRegisterCommand("correlation-1", "client-a")); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(0, reply.Hresult); Assert.Equal(42, reply.Register.ServerHandle); Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType); Assert.Equal(42, reply.ReturnValue.Int32Value); Assert.Equal(runtime.StaThreadId, factory.FakeComObject.RegisterThreadId); Assert.Equal("client-a", factory.FakeComObject.RegisteredClientName); RegisteredServerHandle registeredServerHandle = Assert.Single( await session.GetRegisteredServerHandlesAsync()); Assert.Equal(42, registeredServerHandle.ServerHandle); Assert.Equal("client-a", registeredServerHandle.ClientName); } /// Verifies that Unregister command calls MXAccess on the STA thread and removes the tracked server handle. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Unregister_CallsMxAccessOnStaAndRemovesTrackedServerHandle() { FakeMxAccessComObject fakeComObject = new(registerHandle: 43); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register", "client-a")); MxCommandReply reply = await session.DispatchAsync(CreateUnregisterCommand("unregister", 43)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(43, fakeComObject.UnregisteredServerHandle); Assert.Equal(runtime.StaThreadId, fakeComObject.UnregisterThreadId); Assert.Empty(await session.GetRegisteredServerHandlesAsync()); } /// Verifies that Unregister preserves the HResult when MXAccess throws and does not rewrite the failure. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_UnregisterWhenMxAccessThrows_PreservesHResultAndDoesNotRewriteFailure() { const int hresult = unchecked((int)0x80070057); FakeMxAccessComObject fakeComObject = new( registerHandle: 44, unregisterException: new COMException("Invalid handle.", hresult)); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-failure", "client-a")); MxCommandReply reply = await session.DispatchAsync(CreateUnregisterCommand("invalid-unregister", 44)); Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(hresult, reply.Hresult); Assert.Contains("0x80070057", reply.DiagnosticMessage); Assert.Equal(44, fakeComObject.UnregisteredServerHandle); RegisteredServerHandle registeredServerHandle = Assert.Single( await session.GetRegisteredServerHandlesAsync()); Assert.Equal(44, registeredServerHandle.ServerHandle); } /// Verifies that AddItem command calls MXAccess on the STA thread and tracks the item handle. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_AddItem_CallsMxAccessOnStaAndTracksItemHandle() { FakeMxAccessComObject fakeComObject = new( registerHandle: 46, addItemHandle: 501); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-add", "client-a")); MxCommandReply reply = await session.DispatchAsync(CreateAddItemCommand( "add-item", 46, "Galaxy.Tag.Value")); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(0, reply.Hresult); Assert.Equal(501, reply.AddItem.ItemHandle); Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType); Assert.Equal(501, reply.ReturnValue.Int32Value); Assert.Equal(46, fakeComObject.AddItemServerHandle); Assert.Equal("Galaxy.Tag.Value", fakeComObject.AddItemDefinition); Assert.Equal(runtime.StaThreadId, fakeComObject.AddItemThreadId); RegisteredItemHandle registeredItemHandle = Assert.Single( await session.GetRegisteredItemHandlesAsync()); Assert.Equal(46, registeredItemHandle.ServerHandle); Assert.Equal(501, registeredItemHandle.ItemHandle); Assert.Equal("Galaxy.Tag.Value", registeredItemHandle.ItemDefinition); Assert.Equal(string.Empty, registeredItemHandle.ItemContext); Assert.False(registeredItemHandle.HasItemContext); } /// Verifies that AddItem2 command passes the context exactly and tracks the item handle. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_AddItem2_PassesContextExactlyAndTracksItemHandle() { FakeMxAccessComObject fakeComObject = new( registerHandle: 47, addItem2Handle: 502); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-add2", "client-a")); MxCommandReply reply = await session.DispatchAsync(CreateAddItem2Command( "add-item2", 47, "TestInt", "TestChildObject")); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(502, reply.AddItem2.ItemHandle); Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType); Assert.Equal(502, reply.ReturnValue.Int32Value); Assert.Equal(47, fakeComObject.AddItem2ServerHandle); Assert.Equal("TestInt", fakeComObject.AddItem2Definition); Assert.Equal("TestChildObject", fakeComObject.AddItem2Context); Assert.Equal(runtime.StaThreadId, fakeComObject.AddItem2ThreadId); RegisteredItemHandle registeredItemHandle = Assert.Single( await session.GetRegisteredItemHandlesAsync()); Assert.Equal(47, registeredItemHandle.ServerHandle); Assert.Equal(502, registeredItemHandle.ItemHandle); Assert.Equal("TestInt", registeredItemHandle.ItemDefinition); Assert.Equal("TestChildObject", registeredItemHandle.ItemContext); Assert.True(registeredItemHandle.HasItemContext); } /// Verifies that RemoveItem command calls MXAccess on the STA thread and removes the tracked item handle. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_RemoveItem_CallsMxAccessOnStaAndRemovesTrackedItemHandle() { FakeMxAccessComObject fakeComObject = new( registerHandle: 48, addItemHandle: 503); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-remove", "client-a")); await session.DispatchAsync(CreateAddItemCommand("add-before-remove", 48, "Galaxy.Tag.Value")); MxCommandReply reply = await session.DispatchAsync(CreateRemoveItemCommand( "remove-item", 48, 503)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(0, reply.Hresult); Assert.Equal(48, fakeComObject.RemoveItemServerHandle); Assert.Equal(503, fakeComObject.RemovedItemHandle); Assert.Equal(runtime.StaThreadId, fakeComObject.RemoveItemThreadId); Assert.Empty(await session.GetRegisteredItemHandlesAsync()); } /// Verifies that RemoveItem removes tracked advice after MXAccess succeeds on an advised handle. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_RemoveItemWithAdvisedHandle_RemovesTrackedAdviceAfterMxAccessSucceeds() { FakeMxAccessComObject fakeComObject = new( registerHandle: 148, addItemHandle: 603); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-advised-remove", "client-a")); await session.DispatchAsync(CreateAddItemCommand("add-before-advised-remove", 148, "Galaxy.Tag.Value")); await session.DispatchAsync(CreateAdviseCommand("advise-before-remove", 148, 603)); MxCommandReply reply = await session.DispatchAsync(CreateRemoveItemCommand( "remove-advised-item", 148, 603)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Empty(await session.GetRegisteredItemHandlesAsync()); Assert.Empty(await session.GetRegisteredAdviceHandlesAsync()); } /// Verifies that RemoveItem preserves the HResult and keeps the tracked item handle when using a cross-server handle. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_RemoveItemWithCrossServerHandle_PreservesHResultAndKeepsTrackedItemHandle() { const int hresult = unchecked((int)0x80070057); FakeMxAccessComObject fakeComObject = new( registerHandle: 49, addItemHandle: 504, removeItemException: new COMException("Invalid item handle.", hresult)); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-remove-failure", "client-a")); await session.DispatchAsync(CreateAddItemCommand("add-before-remove-failure", 49, "Galaxy.Tag.Value")); MxCommandReply reply = await session.DispatchAsync(CreateRemoveItemCommand( "remove-item-failure", 999, 504)); Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(hresult, reply.Hresult); Assert.Contains("0x80070057", reply.DiagnosticMessage); Assert.Equal(999, fakeComObject.RemoveItemServerHandle); Assert.Equal(504, fakeComObject.RemovedItemHandle); RegisteredItemHandle registeredItemHandle = Assert.Single( await session.GetRegisteredItemHandlesAsync()); Assert.Equal(49, registeredItemHandle.ServerHandle); Assert.Equal(504, registeredItemHandle.ItemHandle); } /// Verifies that AddItem2 preserves the HResult when MXAccess throws and does not track the item handle. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_AddItem2WhenMxAccessThrows_PreservesHResultAndDoesNotTrackItemHandle() { const int hresult = unchecked((int)0x80070057); FakeMxAccessComObject fakeComObject = new( registerHandle: 50, addItem2Exception: new COMException("Invalid server handle.", hresult)); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(CreateAddItem2Command( "add-item2-failure", 9001, "TestInt", "TestChildObject")); Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(hresult, reply.Hresult); Assert.Contains("0x80070057", reply.DiagnosticMessage); Assert.Equal(9001, fakeComObject.AddItem2ServerHandle); Assert.Equal("TestInt", fakeComObject.AddItem2Definition); Assert.Equal("TestChildObject", fakeComObject.AddItem2Context); Assert.Empty(await session.GetRegisteredItemHandlesAsync()); } /// Verifies that Advise command calls MXAccess on the STA thread and tracks plain advice. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Advise_CallsMxAccessOnStaAndTracksPlainAdvice() { FakeMxAccessComObject fakeComObject = new( registerHandle: 52, addItemHandle: 505); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-advise", "client-a")); await session.DispatchAsync(CreateAddItemCommand("add-before-advise", 52, "Galaxy.Tag.Value")); MxCommandReply reply = await session.DispatchAsync(CreateAdviseCommand( "advise", 52, 505)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(0, reply.Hresult); Assert.Equal(52, fakeComObject.AdviseServerHandle); Assert.Equal(505, fakeComObject.AdvisedItemHandle); Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseThreadId); RegisteredAdviceHandle adviceHandle = Assert.Single( await session.GetRegisteredAdviceHandlesAsync()); Assert.Equal(52, adviceHandle.ServerHandle); Assert.Equal(505, adviceHandle.ItemHandle); Assert.Equal(MxAccessAdviceKind.Plain, adviceHandle.AdviceKind); } /// Verifies that AdviseSupervisory calls a distinct MXAccess method and tracks supervisory advice. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_AdviseSupervisory_CallsDistinctMxAccessMethodAndTracksSupervisoryAdvice() { FakeMxAccessComObject fakeComObject = new( registerHandle: 53, addItemHandle: 506); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-supervisory", "client-a")); await session.DispatchAsync(CreateAddItemCommand("add-before-supervisory", 53, "Galaxy.Tag.Value")); MxCommandReply reply = await session.DispatchAsync(CreateAdviseSupervisoryCommand( "advise-supervisory", 53, 506)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(53, fakeComObject.AdviseSupervisoryServerHandle); Assert.Equal(506, fakeComObject.AdviseSupervisoryItemHandle); Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseSupervisoryThreadId); Assert.Null(fakeComObject.AdviseServerHandle); RegisteredAdviceHandle adviceHandle = Assert.Single( await session.GetRegisteredAdviceHandlesAsync()); Assert.Equal(53, adviceHandle.ServerHandle); Assert.Equal(506, adviceHandle.ItemHandle); Assert.Equal(MxAccessAdviceKind.Supervisory, adviceHandle.AdviceKind); } /// Verifies that UnAdvise command calls MXAccess on the STA thread and removes the tracked advice. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_UnAdvise_CallsMxAccessOnStaAndRemovesTrackedAdvice() { FakeMxAccessComObject fakeComObject = new( registerHandle: 54, addItemHandle: 507); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-unadvise", "client-a")); await session.DispatchAsync(CreateAddItemCommand("add-before-unadvise", 54, "Galaxy.Tag.Value")); await session.DispatchAsync(CreateAdviseCommand("advise-before-unadvise", 54, 507)); await session.DispatchAsync(CreateAdviseSupervisoryCommand("supervisory-before-unadvise", 54, 507)); MxCommandReply reply = await session.DispatchAsync(CreateUnAdviseCommand( "unadvise", 54, 507)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(54, fakeComObject.UnAdviseServerHandle); Assert.Equal(507, fakeComObject.UnAdvisedItemHandle); Assert.Equal(runtime.StaThreadId, fakeComObject.UnAdviseThreadId); Assert.Empty(await session.GetRegisteredAdviceHandlesAsync()); } /// Verifies that Advise preserves the HResult when MXAccess throws and does not track the advice. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_AdviseWhenMxAccessThrows_PreservesHResultAndDoesNotTrackAdvice() { const int hresult = unchecked((int)0x80070057); FakeMxAccessComObject fakeComObject = new( registerHandle: 55, addItemHandle: 508, adviseException: new COMException("Invalid item handle.", hresult)); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-advise-failure", "client-a")); await session.DispatchAsync(CreateAddItemCommand("add-before-advise-failure", 55, "Galaxy.Tag.Value")); MxCommandReply reply = await session.DispatchAsync(CreateAdviseCommand( "advise-failure", 55, 999)); Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(hresult, reply.Hresult); Assert.Contains("0x80070057", reply.DiagnosticMessage); Assert.Equal(55, fakeComObject.AdviseServerHandle); Assert.Equal(999, fakeComObject.AdvisedItemHandle); Assert.Empty(await session.GetRegisteredAdviceHandlesAsync()); } /// Verifies that UnAdvise preserves the HResult when MXAccess throws and keeps the tracked advice. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_UnAdviseWhenMxAccessThrows_PreservesHResultAndKeepsTrackedAdvice() { const int hresult = unchecked((int)0x80070057); FakeMxAccessComObject fakeComObject = new( registerHandle: 56, addItemHandle: 509, unAdviseException: new COMException("Invalid item handle.", hresult)); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-unadvise-failure", "client-a")); await session.DispatchAsync(CreateAddItemCommand("add-before-unadvise-failure", 56, "Galaxy.Tag.Value")); await session.DispatchAsync(CreateAdviseCommand("advise-before-unadvise-failure", 56, 509)); MxCommandReply reply = await session.DispatchAsync(CreateUnAdviseCommand( "unadvise-failure", 56, 509)); Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(hresult, reply.Hresult); Assert.Contains("0x80070057", reply.DiagnosticMessage); Assert.Equal(56, fakeComObject.UnAdviseServerHandle); Assert.Equal(509, fakeComObject.UnAdvisedItemHandle); RegisteredAdviceHandle adviceHandle = Assert.Single( await session.GetRegisteredAdviceHandlesAsync()); Assert.Equal(MxAccessAdviceKind.Plain, adviceHandle.AdviceKind); } /// Verifies that SubscribeBulk runs sequential MXAccess calls and returns per-item results. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_SubscribeBulk_RunsSequentialMxAccessCallsAndReturnsPerItemResults() { FakeMxAccessComObject fakeComObject = new( registerHandle: 60, addItemHandle: 512); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(CreateSubscribeBulkCommand( "subscribe-bulk", 60, ["", "Galaxy.Tag.Value"])); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(MxCommandKind.SubscribeBulk, reply.Kind); Assert.Collection( reply.SubscribeBulk.Results, result => { Assert.False(result.WasSuccessful); Assert.Equal(string.Empty, result.TagAddress); Assert.Equal(0, result.ItemHandle); Assert.Contains("required", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); }, result => { Assert.True(result.WasSuccessful); Assert.Equal("Galaxy.Tag.Value", result.TagAddress); Assert.Equal(512, result.ItemHandle); }); Assert.Equal( ["AddItem:60:Galaxy.Tag.Value", "Advise:60:512"], fakeComObject.OperationNames); Assert.Equal(runtime.StaThreadId, fakeComObject.AddItemThreadId); Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseThreadId); } /// /// Verifies that WriteBulk runs MXAccess Write per entry on the STA and returns /// one BulkWriteResult per entry in input order, including a per-entry COM /// failure surfaced as WasSuccessful=false with the underlying HRESULT. /// /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_WriteBulk_RunsSequentialWritesAndReturnsPerEntryResults() { const int hresult = unchecked((int)0x80070057); FakeMxAccessComObject fakeComObject = new( registerHandle: 80, writeExceptionByItemHandle: new Dictionary { [802] = new COMException("Invalid item handle.", hresult), }); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(CreateWriteBulkCommand( "write-bulk", serverHandle: 80, new[] { (itemHandle: 801, value: 11, userId: 5), (itemHandle: 802, value: 22, userId: 5), (itemHandle: 803, value: 33, userId: 5), })); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(MxCommandKind.WriteBulk, reply.Kind); Assert.Equal(3, reply.WriteBulk.Results.Count); BulkWriteResult success1 = reply.WriteBulk.Results[0]; Assert.True(success1.WasSuccessful); Assert.Equal(801, success1.ItemHandle); Assert.Equal(string.Empty, success1.ErrorMessage); BulkWriteResult failure = reply.WriteBulk.Results[1]; Assert.False(failure.WasSuccessful); Assert.Equal(802, failure.ItemHandle); Assert.True(failure.HasHresult); Assert.Equal(hresult, failure.Hresult); BulkWriteResult success3 = reply.WriteBulk.Results[2]; Assert.True(success3.WasSuccessful); Assert.Equal(803, success3.ItemHandle); // Each Write hit the fake COM object on the STA thread. Assert.Equal(runtime.StaThreadId, fakeComObject.WriteThreadId); Assert.Contains("Write:80:801", fakeComObject.OperationNames); Assert.Contains("Write:80:802", fakeComObject.OperationNames); Assert.Contains("Write:80:803", fakeComObject.OperationNames); } /// Verifies that Write2Bulk forwards value AND timestamp to each per-entry Write2. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Write2Bulk_ForwardsValueAndTimestampPerEntry() { FakeMxAccessComObject fakeComObject = new(registerHandle: 81); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc); MxCommandReply reply = await session.DispatchAsync(CreateWrite2BulkCommand( "write2-bulk", serverHandle: 81, new[] { (itemHandle: 811, value: 100, timestamp, userId: 7) })); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); BulkWriteResult result = Assert.Single(reply.Write2Bulk.Results); Assert.True(result.WasSuccessful); Assert.Equal(811, result.ItemHandle); Assert.Equal(100, fakeComObject.WriteValue); Assert.Equal(timestamp, fakeComObject.WriteTimestamp); Assert.Equal(7, fakeComObject.WriteUserId); Assert.Contains("Write2:81:811", fakeComObject.OperationNames); } /// Verifies that WriteSecuredBulk forwards both user ids per entry. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_WriteSecuredBulk_ForwardsUserIdsPerEntry() { FakeMxAccessComObject fakeComObject = new(registerHandle: 82); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredBulkCommand( "write-secured-bulk", serverHandle: 82, new[] { (itemHandle: 821, currentUserId: 11, verifierUserId: 22, value: 555) })); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); BulkWriteResult result = Assert.Single(reply.WriteSecuredBulk.Results); Assert.True(result.WasSuccessful); Assert.Equal(11, fakeComObject.WriteCurrentUserId); Assert.Equal(22, fakeComObject.WriteVerifierUserId); Assert.Equal(555, fakeComObject.WriteValue); Assert.Contains("WriteSecured:82:821", fakeComObject.OperationNames); } /// Verifies that WriteSecured2Bulk forwards user ids, value, and timestamp per entry. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_WriteSecured2Bulk_ForwardsUserIdsValueAndTimestampPerEntry() { FakeMxAccessComObject fakeComObject = new(registerHandle: 83); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc); MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2BulkCommand( "write-secured2-bulk", serverHandle: 83, new[] { (itemHandle: 831, currentUserId: 33, verifierUserId: 44, value: 999, timestamp) })); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); BulkWriteResult result = Assert.Single(reply.WriteSecured2Bulk.Results); Assert.True(result.WasSuccessful); Assert.Equal(33, fakeComObject.WriteCurrentUserId); Assert.Equal(44, fakeComObject.WriteVerifierUserId); Assert.Equal(999, fakeComObject.WriteValue); Assert.Equal(timestamp, fakeComObject.WriteTimestamp); Assert.Contains("WriteSecured2:83:831", fakeComObject.OperationNames); } /// /// Verifies ReadBulk's snapshot path: with no cached value, the worker takes /// the AddItem + Advise + wait + UnAdvise + RemoveItem lifecycle itself, and /// surfaces a timeout as a per-tag failure when no OnDataChange arrives. /// The fake COM object never fires events so the wait always times out — but /// the lifecycle calls must still happen, in order, on the STA. /// /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_ReadBulk_WhenTagNotCached_TakesSnapshotLifecycleAndTimesOut() { FakeMxAccessComObject fakeComObject = new( registerHandle: 90, addItemHandle: 900); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-read-bulk", "client-a")); MxCommandReply reply = await session.DispatchAsync(CreateReadBulkCommand( "read-bulk-snapshot", serverHandle: 90, tagAddresses: new[] { "Galaxy.Tag.Value" }, timeoutMs: 80)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(MxCommandKind.ReadBulk, reply.Kind); BulkReadResult result = Assert.Single(reply.ReadBulk.Results); Assert.False(result.WasSuccessful); Assert.False(result.WasCached); Assert.Equal("Galaxy.Tag.Value", result.TagAddress); Assert.Equal(900, result.ItemHandle); Assert.Contains("timed out", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); // The snapshot lifecycle must call AddItem → Advise → UnAdvise → RemoveItem // in order on the STA. We don't assert exact ordering of UnAdvise vs. // RemoveItem here because both are best-effort cleanup in a finally // block; the operation list confirms both happened. Assert.Contains("AddItem:90:Galaxy.Tag.Value", fakeComObject.OperationNames); Assert.Contains("Advise:90:900", fakeComObject.OperationNames); Assert.Contains("UnAdvise:90:900", fakeComObject.OperationNames); Assert.Contains("RemoveItem:90:900", fakeComObject.OperationNames); } /// Verifies that ReadBulk with no payload returns an invalid request error. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_ReadBulkWithoutPayload_ReturnsInvalidRequest() { FakeMxAccessComObject fakeComObject = new(registerHandle: 91); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(new StaCommand( "session-1", "missing-read-bulk-payload", new MxCommand { Kind = MxCommandKind.ReadBulk, })); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } /// Verifies that UnsubscribeBulk removes items after UnAdvise failure. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_UnsubscribeBulk_RemovesItemAfterUnAdviseFailure() { const int hresult = unchecked((int)0x80070057); FakeMxAccessComObject fakeComObject = new( registerHandle: 61, unAdviseException: new COMException("Invalid item handle.", hresult)); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(CreateUnsubscribeBulkCommand( "unsubscribe-bulk", 61, [513])); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); SubscribeResult result = Assert.Single(reply.UnsubscribeBulk.Results); Assert.False(result.WasSuccessful); Assert.Equal(513, result.ItemHandle); Assert.Equal(string.Empty, result.TagAddress); Assert.Contains("UnAdvise failed", result.ErrorMessage); Assert.Equal( ["UnAdvise:61:513", "RemoveItem:61:513"], fakeComObject.OperationNames); } /// Verifies that ShutdownGracefullyAsync cleans up handles in advice, item, server order. /// A task that represents the asynchronous operation. [Fact] public async Task ShutdownGracefullyAsync_CleansHandlesInAdviceItemServerOrder() { FakeMxAccessComObject fakeComObject = new( registerHandle: 58, addItemHandle: 510); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-shutdown", "client-a")); await session.DispatchAsync(CreateAddItemCommand("add-before-shutdown", 58, "Galaxy.Tag.Value")); await session.DispatchAsync(CreateAdviseCommand("advise-before-shutdown", 58, 510)); await session.DispatchAsync(CreateAdviseSupervisoryCommand("supervisory-before-shutdown", 58, 510)); MxAccessShutdownResult result = await session.ShutdownGracefullyAsync(TimeSpan.FromSeconds(2)); Assert.True(result.Succeeded); Assert.Equal( new[] { "UnAdvise:58:510", "RemoveItem:58:510", "Unregister:58" }, fakeComObject.OperationNames.Where(name => name.StartsWith("Un", StringComparison.Ordinal) || name.StartsWith("Remove", StringComparison.Ordinal))); } /// Verifies that ShutdownGracefullyAsync records cleanup failures and continues. /// A task that represents the asynchronous operation. [Fact] public async Task ShutdownGracefullyAsync_RecordsCleanupFailuresAndContinues() { const int hresult = unchecked((int)0x80070057); COMException cleanupException = new("Invalid handle.", hresult); FakeMxAccessComObject fakeComObject = new( registerHandle: 59, addItemHandle: 511, unregisterException: cleanupException, removeItemException: cleanupException, unAdviseException: cleanupException); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-shutdown-failure", "client-a")); await session.DispatchAsync(CreateAddItemCommand("add-before-shutdown-failure", 59, "Galaxy.Tag.Value")); await session.DispatchAsync(CreateAdviseCommand("advise-before-shutdown-failure", 59, 511)); MxAccessShutdownResult result = await session.ShutdownGracefullyAsync(TimeSpan.FromSeconds(2)); Assert.False(result.Succeeded); Assert.Equal(new[] { "UnAdvise", "RemoveItem", "Unregister" }, result.Failures.Select(failure => failure.Operation)); Assert.All(result.Failures, failure => Assert.Equal(hresult, failure.HResult)); Assert.Contains("Unregister:59", fakeComObject.OperationNames); } /// Verifies that Register without payload returns an invalid request error. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_RegisterWithoutPayload_ReturnsInvalidRequest() { FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 45)); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(new StaCommand( "session-1", "missing-payload", new MxCommand { Kind = MxCommandKind.Register, })); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Null(factory.FakeComObject.RegisteredClientName); } /// Verifies that AddItem without payload returns an invalid request error. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_AddItemWithoutPayload_ReturnsInvalidRequest() { FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 51)); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(new StaCommand( "session-1", "missing-add-payload", new MxCommand { Kind = MxCommandKind.AddItem, })); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Null(factory.FakeComObject.AddItemDefinition); } /// Verifies that Advise without payload returns an invalid request error. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_AdviseWithoutPayload_ReturnsInvalidRequest() { FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 57)); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(new StaCommand( "session-1", "missing-advise-payload", new MxCommand { Kind = MxCommandKind.Advise, })); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Null(factory.FakeComObject.AdviseServerHandle); } /// Verifies that Write dispatches the converted value to MXAccess on the STA thread. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Write_CallsMxAccessOnStaWithConvertedValue() { FakeMxAccessComObject fakeComObject = new(registerHandle: 70); 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(CreateWriteCommand( "write", serverHandle: 70, itemHandle: 700, value: 123, userId: 5)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(MxCommandKind.Write, reply.Kind); Assert.Equal(70, fakeComObject.WriteServerHandle); Assert.Equal(700, fakeComObject.WriteItemHandle); Assert.Equal(123, fakeComObject.WriteValue); Assert.Equal(5, fakeComObject.WriteUserId); Assert.Equal(runtime.StaThreadId, fakeComObject.WriteThreadId); } /// Verifies that Write2 forwards the converted value and timestamp to MXAccess. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Write2_ForwardsValueAndTimestamp() { FakeMxAccessComObject fakeComObject = new(registerHandle: 71); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); // Same rationale as the Write forwarding test above. session.WriteCompletionTimeout = TimeSpan.Zero; await session.StartAsync(workerProcessId: 1234); DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc); MxCommandReply reply = await session.DispatchAsync(CreateWrite2Command( "write2", serverHandle: 71, itemHandle: 710, value: 456, timestamp: timestamp, userId: 6)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(MxCommandKind.Write2, reply.Kind); Assert.Equal(710, fakeComObject.WriteItemHandle); Assert.Equal(456, fakeComObject.WriteValue); Assert.Equal(timestamp, fakeComObject.WriteTimestamp); Assert.Equal(6, fakeComObject.WriteUserId); } /// Verifies that WriteSecured forwards the operator and verifier user ids to MXAccess. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_WriteSecured_ForwardsUserIds() { FakeMxAccessComObject fakeComObject = new(registerHandle: 72); 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( "write-secured", serverHandle: 72, itemHandle: 720, value: 789, currentUserId: 11, verifierUserId: 22)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(MxCommandKind.WriteSecured, reply.Kind); Assert.Equal(720, fakeComObject.WriteItemHandle); Assert.Equal(789, fakeComObject.WriteValue); Assert.Equal(11, fakeComObject.WriteCurrentUserId); Assert.Equal(22, fakeComObject.WriteVerifierUserId); } /// Verifies that WriteSecured2 forwards user ids, value, and timestamp to MXAccess. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_WriteSecured2_ForwardsUserIdsValueAndTimestamp() { FakeMxAccessComObject fakeComObject = new(registerHandle: 73); 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); MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2Command( "write-secured2", serverHandle: 73, itemHandle: 730, value: 1011, timestamp: timestamp, currentUserId: 33, verifierUserId: 44)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(MxCommandKind.WriteSecured2, reply.Kind); Assert.Equal(1011, fakeComObject.WriteValue); Assert.Equal(timestamp, fakeComObject.WriteTimestamp); Assert.Equal(33, fakeComObject.WriteCurrentUserId); 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.OnWriteCallback = () => sink.WriteCompletionCache.Record(82, 820, CreateCompletionRows(detail: 4321)); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, sink); // Hermetic: don't inherit MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS // from the test runner's environment. session.WriteCompletionTimeout = TimeSpan.FromSeconds(10); 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(); // Deterministic ordering: the executor captures its version baseline // BEFORE the COM call, so once the fake's WriteSecured has run the // baseline is committed and a Record from the test thread is // guaranteed to be "newer" — no fixed sleep racing the STA thread. using System.Threading.ManualResetEventSlim comCallReached = new(initialState: false); fakeComObject.OnWriteCallback = () => comCallReached.Set(); 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)); Assert.True(comCallReached.Wait(TimeSpan.FromSeconds(5))); 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.OnWriteCallback = () => sink.WriteCompletionCache.Record(86, 860, CreateCompletionRows(detail: 2222)); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, sink); // Hermetic: same rationale as the WriteSecured fast-completion test. session.WriteCompletionTimeout = TimeSpan.FromSeconds(10); 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 correlates the same way as WriteSecured /// (fast-completion edge): OtOpcUa's dominant FreeAccess path goes out /// as MX_COMMAND_KIND_WRITE, so its reply must carry the OnWriteComplete /// rows too. /// /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Write_WhenCompletionArrivesDuringComCall_ReturnsStatuses() { FakeMxAccessComObject fakeComObject = new(registerHandle: 87); FakeMxAccessComObjectFactory factory = new(fakeComObject); CompletionCacheEventSink sink = new(); fakeComObject.OnWriteCallback = () => sink.WriteCompletionCache.Record(87, 870, CreateCompletionRows(detail: 5555)); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, sink); // Hermetic: don't inherit MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS // from the test runner's environment. session.WriteCompletionTimeout = TimeSpan.FromSeconds(10); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand( "plain-write-fast", serverHandle: 87, itemHandle: 870, value: 1, userId: 5)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(0, reply.Hresult); MxStatusProxy row = Assert.Single(reply.Statuses); Assert.Equal(5555, row.Detail); Assert.Equal(MxStatusCategory.Ok, row.Category); } /// /// Verifies the plain-Write timeout fallback mirrors the secured one: /// no completion within the bounded wait returns protocol OK with EMPTY /// statuses — unconfirmed, never a synthesized failure row. /// /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Write_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol() { FakeMxAccessComObject fakeComObject = new(registerHandle: 88); 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(CreateWriteCommand( "plain-write-timeout", serverHandle: 88, itemHandle: 880, value: 1, userId: 5)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.True(reply.HasHresult); Assert.Equal(0, reply.Hresult); Assert.Empty(reply.Statuses); } /// /// Verifies Write2 correlates the same way as Write (fast-completion /// edge). /// /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Write2_WhenCompletionArrivesDuringComCall_ReturnsStatuses() { FakeMxAccessComObject fakeComObject = new(registerHandle: 89); FakeMxAccessComObjectFactory factory = new(fakeComObject); CompletionCacheEventSink sink = new(); fakeComObject.OnWriteCallback = () => sink.WriteCompletionCache.Record(89, 890, CreateCompletionRows(detail: 6666)); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, sink); session.WriteCompletionTimeout = TimeSpan.FromSeconds(10); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(CreateWrite2Command( "plain-write2-fast", serverHandle: 89, itemHandle: 890, value: 1, timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), userId: 6)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(6666, Assert.Single(reply.Statuses).Detail); } /// /// Verifies WriteBulk stays fire-and-forget: even with a huge completion /// timeout configured and no completion source, the reply returns /// immediately with per-entry results — bulk writes never enter the /// bounded wait (latency for high-rate loops). /// /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_WriteBulk_DoesNotWaitForCompletion() { FakeMxAccessComObject fakeComObject = new(registerHandle: 90); 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(CreateWriteBulkCommand( "bulk-write-no-wait", serverHandle: 90, entries: new[] { (itemHandle: 900, 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] public async Task DispatchAsync_WriteWithoutPayload_ReturnsInvalidRequest() { FakeMxAccessComObject fakeComObject = new(registerHandle: 74); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(new StaCommand( "session-1", "missing-write-payload", new MxCommand { Kind = MxCommandKind.Write, })); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Null(fakeComObject.WriteServerHandle); } /// Verifies that Write without a value returns an invalid request error. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_WriteWithoutValue_ReturnsInvalidRequest() { FakeMxAccessComObject fakeComObject = new(registerHandle: 75); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(new StaCommand( "session-1", "missing-write-value", new MxCommand { Kind = MxCommandKind.Write, Write = new WriteCommand { ServerHandle = 75, ItemHandle = 750, }, })); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Null(fakeComObject.WriteServerHandle); } /// Verifies Suspend calls MXAccess on the STA and maps the native status to MxStatusProxy. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Suspend_CallsMxAccessOnStaAndMapsStatus() { FakeMxAccessComObject fakeComObject = new(registerHandle: 200); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-suspend", "client-a")); MxCommandReply reply = await session.DispatchAsync(CreateSuspendCommand("suspend-1", 200, 21)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(0, reply.Hresult); Assert.NotNull(reply.Suspend); Assert.NotNull(reply.Suspend.Status); Assert.Equal(1, reply.Suspend.Status.Success); Assert.Equal(MxStatusCategory.Ok, reply.Suspend.Status.Category); Assert.Equal(200, fakeComObject.SuspendServerHandle); Assert.Equal(21, fakeComObject.SuspendItemHandle); Assert.Equal(runtime.StaThreadId, fakeComObject.SuspendThreadId); } /// Verifies Activate calls MXAccess on the STA and maps the native status to MxStatusProxy. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_Activate_CallsMxAccessOnStaAndMapsStatus() { FakeMxAccessComObject fakeComObject = new(registerHandle: 201); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-activate", "client-a")); MxCommandReply reply = await session.DispatchAsync(CreateActivateCommand("activate-1", 201, 22)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.NotNull(reply.Activate); Assert.NotNull(reply.Activate.Status); Assert.Equal(1, reply.Activate.Status.Success); Assert.Equal(MxStatusCategory.Ok, reply.Activate.Status.Category); Assert.Equal(201, fakeComObject.ActivateServerHandle); Assert.Equal(22, fakeComObject.ActivateItemHandle); Assert.Equal(runtime.StaThreadId, fakeComObject.ActivateThreadId); } /// Verifies AuthenticateUser passes credentials to MXAccess on the STA and returns the user id. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_AuthenticateUser_CallsMxAccessOnStaAndReturnsUserId() { FakeMxAccessComObject fakeComObject = new(registerHandle: 202); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-auth", "client-a")); MxCommandReply reply = await session.DispatchAsync( CreateAuthenticateUserCommand("auth-1", 202, "Administrator", string.Empty)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.NotNull(reply.AuthenticateUser); Assert.Equal(1, reply.AuthenticateUser.UserId); Assert.Equal(202, fakeComObject.AuthenticateServerHandle); Assert.Equal("Administrator", fakeComObject.AuthenticateUserName); Assert.Equal(runtime.StaThreadId, fakeComObject.AuthenticateThreadId); } /// /// Verifies the AuthenticateUser path never surfaces the credential into the /// command reply or any recorded diagnostic — the password is only ever /// handed straight to the MXAccess wrapper. /// /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_AuthenticateUser_DoesNotLeakPassword() { const string secret = "sup3r-secret-pw"; FakeMxAccessComObject fakeComObject = new(registerHandle: 203); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-auth-leak", "client-a")); MxCommandReply reply = await session.DispatchAsync( CreateAuthenticateUserCommand("auth-leak", 203, "Administrator", secret)); // The wrapper still receives the credential verbatim... Assert.Equal(secret, fakeComObject.AuthenticatePassword); // ...but the reply (diagnostics, status text) and the fake's operation // log must never contain it. Assert.DoesNotContain(secret, reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal); Assert.DoesNotContain(secret, reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal); Assert.DoesNotContain(fakeComObject.OperationNames, name => name.Contains(secret, StringComparison.Ordinal)); } /// Verifies ArchestrAUserToId calls MXAccess on the STA and returns the resolved user id. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_ArchestrAUserToId_CallsMxAccessOnStaAndReturnsUserId() { FakeMxAccessComObject fakeComObject = new(registerHandle: 204); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-user-to-id", "client-a")); MxCommandReply reply = await session.DispatchAsync( CreateArchestrAUserToIdCommand("user-to-id-1", 204, "11112222-3333-4444-5555-666677778888")); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.NotNull(reply.ArchestraUserToId); Assert.Equal(7, reply.ArchestraUserToId.UserId); Assert.Equal(204, fakeComObject.ArchestrAUserToIdServerHandle); Assert.Equal("11112222-3333-4444-5555-666677778888", fakeComObject.ArchestrAUserToIdGuid); } /// Verifies AddBufferedItem calls MXAccess on the STA and tracks the buffered item handle. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_AddBufferedItem_CallsMxAccessOnStaAndTracksItemHandle() { FakeMxAccessComObject fakeComObject = new(registerHandle: 205); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-buffered", "client-a")); MxCommandReply reply = await session.DispatchAsync( CreateAddBufferedItemCommand("buffered-1", 205, "TestInt", "TestChildObject")); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.NotNull(reply.AddBufferedItem); Assert.Equal(1, reply.AddBufferedItem.ItemHandle); Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType); Assert.Equal(1, reply.ReturnValue.Int32Value); Assert.Equal(205, fakeComObject.AddBufferedItemServerHandle); Assert.Equal("TestInt", fakeComObject.AddBufferedItemDefinition); Assert.Equal("TestChildObject", fakeComObject.AddBufferedItemContext); RegisteredItemHandle registeredItemHandle = Assert.Single( await session.GetRegisteredItemHandlesAsync()); Assert.Equal(205, registeredItemHandle.ServerHandle); Assert.Equal(1, registeredItemHandle.ItemHandle); Assert.Equal("TestInt", registeredItemHandle.ItemDefinition); Assert.Equal("TestChildObject", registeredItemHandle.ItemContext); Assert.True(registeredItemHandle.HasItemContext); } /// Verifies SetBufferedUpdateInterval calls MXAccess on the STA and returns a base OK reply. /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_SetBufferedUpdateInterval_CallsMxAccessOnStaAndReturnsOk() { FakeMxAccessComObject fakeComObject = new(registerHandle: 206); FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); await session.DispatchAsync(CreateRegisterCommand("register-before-interval", "client-a")); MxCommandReply reply = await session.DispatchAsync( CreateSetBufferedUpdateIntervalCommand("interval-1", 206, 500)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(0, reply.Hresult); Assert.Equal(206, fakeComObject.SetBufferedUpdateIntervalServerHandle); Assert.Equal(500, fakeComObject.SetBufferedUpdateIntervalValue); } /// /// Verifies that a command with an unknown value returns an /// reply whose diagnostic contains "Unsupported". /// This pins the _ => CreateInvalidRequestReply(...) discard arm in /// MxAccessCommandExecutor.Execute: a regression that changed the arm to /// throw would propagate an unhandled exception through WorkerPipeSession /// and no other test would catch it. /// /// A task that represents the asynchronous operation. [Fact] public async Task DispatchAsync_WithUnknownCommandKind_ReturnsInvalidRequestWithUnsupportedDiagnostic() { FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 999)); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); await session.StartAsync(workerProcessId: 1234); // Cast an integer outside the defined MxCommandKind range to an unknown kind value. MxCommandKind unknownKind = (MxCommandKind)int.MaxValue; MxCommandReply reply = await session.DispatchAsync(new StaCommand( "session-1", "unknown-kind-correlation", new MxCommand { Kind = unknownKind, })); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Contains("Unsupported", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase); } private static StaCommand CreateSuspendCommand( string correlationId, int serverHandle, int itemHandle) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.Suspend, Suspend = new SuspendCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, }, }); } private static StaCommand CreateActivateCommand( string correlationId, int serverHandle, int itemHandle) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.Activate, Activate = new ActivateCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, }, }); } private static StaCommand CreateAuthenticateUserCommand( string correlationId, int serverHandle, string verifyUser, string verifyUserPassword) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.AuthenticateUser, AuthenticateUser = new AuthenticateUserCommand { ServerHandle = serverHandle, VerifyUser = verifyUser, VerifyUserPassword = verifyUserPassword, }, }); } private static StaCommand CreateArchestrAUserToIdCommand( string correlationId, int serverHandle, string userIdGuid) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.ArchestraUserToId, ArchestraUserToId = new ArchestrAUserToIdCommand { ServerHandle = serverHandle, UserIdGuid = userIdGuid, }, }); } private static StaCommand CreateAddBufferedItemCommand( string correlationId, int serverHandle, string itemDefinition, string itemContext) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.AddBufferedItem, AddBufferedItem = new AddBufferedItemCommand { ServerHandle = serverHandle, ItemDefinition = itemDefinition, ItemContext = itemContext, }, }); } private static StaCommand CreateSetBufferedUpdateIntervalCommand( string correlationId, int serverHandle, int updateIntervalMilliseconds) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.SetBufferedUpdateInterval, SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand { ServerHandle = serverHandle, UpdateIntervalMilliseconds = updateIntervalMilliseconds, }, }); } private static StaCommand CreateRegisterCommand( string correlationId, string clientName) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.Register, Register = new RegisterCommand { ClientName = clientName, }, }); } private static StaCommand CreateUnregisterCommand( string correlationId, int serverHandle) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.Unregister, Unregister = new UnregisterCommand { ServerHandle = serverHandle, }, }); } private static StaCommand CreateAddItemCommand( string correlationId, int serverHandle, string itemDefinition) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.AddItem, AddItem = new AddItemCommand { ServerHandle = serverHandle, ItemDefinition = itemDefinition, }, }); } private static StaCommand CreateAddItem2Command( string correlationId, int serverHandle, string itemDefinition, string itemContext) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.AddItem2, AddItem2 = new AddItem2Command { ServerHandle = serverHandle, ItemDefinition = itemDefinition, ItemContext = itemContext, }, }); } private static StaCommand CreateRemoveItemCommand( string correlationId, int serverHandle, int itemHandle) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.RemoveItem, RemoveItem = new RemoveItemCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, }, }); } private static StaCommand CreateAdviseCommand( string correlationId, int serverHandle, int itemHandle) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.Advise, Advise = new AdviseCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, }, }); } private static MxValue CreateIntegerValue(int value) { return new MxValue { DataType = MxDataType.Integer, VariantType = "VT_I4", Int32Value = value, }; } private static MxValue CreateTimestampValue(DateTime timestamp) { return new MxValue { DataType = MxDataType.Time, VariantType = "VT_DATE", TimestampValue = Timestamp.FromDateTime(timestamp), }; } private static StaCommand CreateWriteCommand( string correlationId, int serverHandle, int itemHandle, int value, int userId) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.Write, Write = new WriteCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, Value = CreateIntegerValue(value), UserId = userId, }, }); } private static StaCommand CreateWrite2Command( string correlationId, int serverHandle, int itemHandle, int value, DateTime timestamp, int userId) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.Write2, Write2 = new Write2Command { ServerHandle = serverHandle, ItemHandle = itemHandle, Value = CreateIntegerValue(value), TimestampValue = CreateTimestampValue(timestamp), UserId = userId, }, }); } private static StaCommand CreateWriteSecuredCommand( string correlationId, int serverHandle, int itemHandle, int value, int currentUserId, int verifierUserId) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.WriteSecured, WriteSecured = new WriteSecuredCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, CurrentUserId = currentUserId, VerifierUserId = verifierUserId, Value = CreateIntegerValue(value), }, }); } private static StaCommand CreateWriteSecured2Command( string correlationId, int serverHandle, int itemHandle, int value, DateTime timestamp, int currentUserId, int verifierUserId) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.WriteSecured2, WriteSecured2 = new WriteSecured2Command { ServerHandle = serverHandle, ItemHandle = itemHandle, CurrentUserId = currentUserId, VerifierUserId = verifierUserId, Value = CreateIntegerValue(value), TimestampValue = CreateTimestampValue(timestamp), }, }); } private static StaCommand CreateUnAdviseCommand( string correlationId, int serverHandle, int itemHandle) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.UnAdvise, UnAdvise = new UnAdviseCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, }, }); } private static StaCommand CreateSubscribeBulkCommand( string correlationId, int serverHandle, IEnumerable tagAddresses) { SubscribeBulkCommand command = new() { ServerHandle = serverHandle, }; command.TagAddresses.Add(tagAddresses); return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.SubscribeBulk, SubscribeBulk = command, }); } private static StaCommand CreateUnsubscribeBulkCommand( string correlationId, int serverHandle, IEnumerable itemHandles) { UnsubscribeBulkCommand command = new() { ServerHandle = serverHandle, }; command.ItemHandles.Add(itemHandles); return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.UnsubscribeBulk, UnsubscribeBulk = command, }); } private static StaCommand CreateWriteBulkCommand( string correlationId, int serverHandle, IEnumerable<(int itemHandle, int value, int userId)> entries) { WriteBulkCommand command = new() { ServerHandle = serverHandle, }; foreach ((int itemHandle, int value, int userId) in entries) { command.Entries.Add(new WriteBulkEntry { ItemHandle = itemHandle, Value = CreateIntegerValue(value), UserId = userId, }); } return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.WriteBulk, WriteBulk = command, }); } private static StaCommand CreateWrite2BulkCommand( string correlationId, int serverHandle, IEnumerable<(int itemHandle, int value, DateTime timestamp, int userId)> entries) { Write2BulkCommand command = new() { ServerHandle = serverHandle, }; foreach ((int itemHandle, int value, DateTime timestamp, int userId) in entries) { command.Entries.Add(new Write2BulkEntry { ItemHandle = itemHandle, Value = CreateIntegerValue(value), TimestampValue = CreateTimestampValue(timestamp), UserId = userId, }); } return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.Write2Bulk, Write2Bulk = command, }); } private static StaCommand CreateWriteSecuredBulkCommand( string correlationId, int serverHandle, IEnumerable<(int itemHandle, int currentUserId, int verifierUserId, int value)> entries) { WriteSecuredBulkCommand command = new() { ServerHandle = serverHandle, }; foreach ((int itemHandle, int currentUserId, int verifierUserId, int value) in entries) { command.Entries.Add(new WriteSecuredBulkEntry { ItemHandle = itemHandle, CurrentUserId = currentUserId, VerifierUserId = verifierUserId, Value = CreateIntegerValue(value), }); } return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.WriteSecuredBulk, WriteSecuredBulk = command, }); } private static StaCommand CreateWriteSecured2BulkCommand( string correlationId, int serverHandle, IEnumerable<(int itemHandle, int currentUserId, int verifierUserId, int value, DateTime timestamp)> entries) { WriteSecured2BulkCommand command = new() { ServerHandle = serverHandle, }; foreach ((int itemHandle, int currentUserId, int verifierUserId, int value, DateTime timestamp) in entries) { command.Entries.Add(new WriteSecured2BulkEntry { ItemHandle = itemHandle, CurrentUserId = currentUserId, VerifierUserId = verifierUserId, Value = CreateIntegerValue(value), TimestampValue = CreateTimestampValue(timestamp), }); } return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.WriteSecured2Bulk, WriteSecured2Bulk = command, }); } private static StaCommand CreateReadBulkCommand( string correlationId, int serverHandle, IEnumerable tagAddresses, uint timeoutMs) { ReadBulkCommand command = new() { ServerHandle = serverHandle, TimeoutMs = timeoutMs, }; command.TagAddresses.Add(tagAddresses); return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.ReadBulk, ReadBulk = command, }); } private static StaCommand CreateAdviseSupervisoryCommand( string correlationId, int serverHandle, int itemHandle) { return new StaCommand( "session-1", correlationId, new MxCommand { Kind = MxCommandKind.AdviseSupervisory, AdviseSupervisory = new AdviseSupervisoryCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, }, }); } private static StaRuntime CreateRuntime() { return new StaRuntime( new NoopComApartmentInitializer(), new StaMessagePump(), 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; private readonly int addItemHandle; private readonly int addItem2Handle; private readonly Exception? unregisterException; private readonly Exception? addItemException; private readonly Exception? addItem2Exception; private readonly Exception? removeItemException; private readonly Exception? adviseException; private readonly Exception? unAdviseException; private readonly Exception? adviseSupervisoryException; private readonly IReadOnlyDictionary writeExceptionByItemHandle; private readonly List operationNames = new(); /// /// Invoked at the end of a successful Write/Write2/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 on every correlated write kind. /// public Action? OnWriteCallback { 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. /// Return value for AddItem2 method. /// Exception to throw from Unregister, if any. /// Exception to throw from AddItem, if any. /// Exception to throw from AddItem2, if any. /// Exception to throw from RemoveItem, if any. /// Exception to throw from Advise, if any. /// Exception to throw from UnAdvise, if any. /// Exception to throw from AdviseSupervisory, if any. /// Map of item handles to exceptions thrown on write. public FakeMxAccessComObject( int registerHandle, int addItemHandle = 0, int addItem2Handle = 0, Exception? unregisterException = null, Exception? addItemException = null, Exception? addItem2Exception = null, Exception? removeItemException = null, Exception? adviseException = null, Exception? unAdviseException = null, Exception? adviseSupervisoryException = null, IReadOnlyDictionary? writeExceptionByItemHandle = null) { this.registerHandle = registerHandle; this.addItemHandle = addItemHandle; this.addItem2Handle = addItem2Handle; this.unregisterException = unregisterException; this.addItemException = addItemException; this.addItem2Exception = addItem2Exception; this.removeItemException = removeItemException; this.adviseException = adviseException; this.unAdviseException = unAdviseException; this.adviseSupervisoryException = adviseSupervisoryException; this.writeExceptionByItemHandle = writeExceptionByItemHandle ?? new Dictionary(); } /// Gets the client name passed to Register, if called. public string? RegisteredClientName { get; private set; } /// Gets the thread ID on which Register was called. public int? RegisterThreadId { get; private set; } /// Gets the server handle passed to Unregister, if called. public int? UnregisteredServerHandle { get; private set; } /// Gets the thread ID on which Unregister was called. public int? UnregisterThreadId { get; private set; } /// Gets the server handle passed to AddItem, if called. public int? AddItemServerHandle { get; private set; } /// Gets the item definition passed to AddItem, if called. public string? AddItemDefinition { get; private set; } /// Gets the thread ID on which AddItem was called. public int? AddItemThreadId { get; private set; } /// Gets the server handle passed to AddItem2, if called. public int? AddItem2ServerHandle { get; private set; } /// Gets the item definition passed to AddItem2, if called. public string? AddItem2Definition { get; private set; } /// Gets the item context passed to AddItem2, if called. public string? AddItem2Context { get; private set; } /// Gets the thread ID on which AddItem2 was called. public int? AddItem2ThreadId { get; private set; } /// Gets the server handle passed to RemoveItem, if called. public int? RemoveItemServerHandle { get; private set; } /// Gets the item handle passed to RemoveItem, if called. public int? RemovedItemHandle { get; private set; } /// Gets the thread ID on which RemoveItem was called. public int? RemoveItemThreadId { get; private set; } /// Gets the server handle passed to Advise, if called. public int? AdviseServerHandle { get; private set; } /// Gets the item handle passed to Advise, if called. public int? AdvisedItemHandle { get; private set; } /// Gets the thread ID on which Advise was called. public int? AdviseThreadId { get; private set; } /// Gets the server handle passed to UnAdvise, if called. public int? UnAdviseServerHandle { get; private set; } /// Gets the item handle passed to UnAdvise, if called. public int? UnAdvisedItemHandle { get; private set; } /// Gets the thread ID on which UnAdvise was called. public int? UnAdviseThreadId { get; private set; } /// Gets the server handle passed to AdviseSupervisory, if called. public int? AdviseSupervisoryServerHandle { get; private set; } /// Gets the item handle passed to AdviseSupervisory, if called. public int? AdviseSupervisoryItemHandle { get; private set; } /// Gets the thread ID on which AdviseSupervisory was called. public int? AdviseSupervisoryThreadId { get; private set; } /// Gets the list of operations performed on this fake object. public IReadOnlyList OperationNames => operationNames.ToArray(); /// public int Register(string clientName) { operationNames.Add($"Register:{clientName}"); RegisteredClientName = clientName; RegisterThreadId = Environment.CurrentManagedThreadId; return registerHandle; } /// public void Unregister(int serverHandle) { operationNames.Add($"Unregister:{serverHandle}"); UnregisteredServerHandle = serverHandle; UnregisterThreadId = Environment.CurrentManagedThreadId; if (unregisterException is not null) { throw unregisterException; } } /// public int AddItem( int serverHandle, string itemDefinition) { operationNames.Add($"AddItem:{serverHandle}:{itemDefinition}"); AddItemServerHandle = serverHandle; AddItemDefinition = itemDefinition; AddItemThreadId = Environment.CurrentManagedThreadId; if (addItemException is not null) { throw addItemException; } return addItemHandle; } /// public int AddItem2( int serverHandle, string itemDefinition, string itemContext) { operationNames.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}"); AddItem2ServerHandle = serverHandle; AddItem2Definition = itemDefinition; AddItem2Context = itemContext; AddItem2ThreadId = Environment.CurrentManagedThreadId; if (addItem2Exception is not null) { throw addItem2Exception; } return addItem2Handle; } /// public void RemoveItem( int serverHandle, int itemHandle) { operationNames.Add($"RemoveItem:{serverHandle}:{itemHandle}"); RemoveItemServerHandle = serverHandle; RemovedItemHandle = itemHandle; RemoveItemThreadId = Environment.CurrentManagedThreadId; if (removeItemException is not null) { throw removeItemException; } } /// public void Advise( int serverHandle, int itemHandle) { operationNames.Add($"Advise:{serverHandle}:{itemHandle}"); AdviseServerHandle = serverHandle; AdvisedItemHandle = itemHandle; AdviseThreadId = Environment.CurrentManagedThreadId; if (adviseException is not null) { throw adviseException; } } /// public void UnAdvise( int serverHandle, int itemHandle) { operationNames.Add($"UnAdvise:{serverHandle}:{itemHandle}"); UnAdviseServerHandle = serverHandle; UnAdvisedItemHandle = itemHandle; UnAdviseThreadId = Environment.CurrentManagedThreadId; if (unAdviseException is not null) { throw unAdviseException; } } /// public void AdviseSupervisory( int serverHandle, int itemHandle) { operationNames.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}"); AdviseSupervisoryServerHandle = serverHandle; AdviseSupervisoryItemHandle = itemHandle; AdviseSupervisoryThreadId = Environment.CurrentManagedThreadId; if (adviseSupervisoryException is not null) { throw adviseSupervisoryException; } } /// Gets the server handle passed to the most recent write, if called. public int? WriteServerHandle { get; private set; } /// Gets the item handle passed to the most recent write, if called. public int? WriteItemHandle { get; private set; } /// Gets the value passed to the most recent write, if called. public object? WriteValue { get; private set; } /// Gets the timestamp passed to the most recent timestamped write, if called. public object? WriteTimestamp { get; private set; } /// Gets the user id passed to the most recent Write/Write2, if called. public int? WriteUserId { get; private set; } /// Gets the current user id passed to the most recent secured write, if called. public int? WriteCurrentUserId { get; private set; } /// Gets the verifier user id passed to the most recent secured write, if called. public int? WriteVerifierUserId { get; private set; } /// Gets the thread ID on which the most recent write was called. public int? WriteThreadId { get; private set; } /// public void Write( int serverHandle, int itemHandle, object? value, int userId) { operationNames.Add($"Write:{serverHandle}:{itemHandle}"); WriteServerHandle = serverHandle; WriteItemHandle = itemHandle; WriteValue = value; WriteUserId = userId; WriteThreadId = Environment.CurrentManagedThreadId; ThrowIfWriteFailureConfigured(itemHandle); OnWriteCallback?.Invoke(); } /// public void Write2( int serverHandle, int itemHandle, object? value, object? timestamp, int userId) { operationNames.Add($"Write2:{serverHandle}:{itemHandle}"); WriteServerHandle = serverHandle; WriteItemHandle = itemHandle; WriteValue = value; WriteTimestamp = timestamp; WriteUserId = userId; WriteThreadId = Environment.CurrentManagedThreadId; ThrowIfWriteFailureConfigured(itemHandle); OnWriteCallback?.Invoke(); } /// public void WriteSecured( int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value) { operationNames.Add($"WriteSecured:{serverHandle}:{itemHandle}"); WriteServerHandle = serverHandle; WriteItemHandle = itemHandle; WriteCurrentUserId = currentUserId; WriteVerifierUserId = verifierUserId; WriteValue = value; WriteThreadId = Environment.CurrentManagedThreadId; ThrowIfWriteFailureConfigured(itemHandle); OnWriteCallback?.Invoke(); } /// public void WriteSecured2( int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp) { operationNames.Add($"WriteSecured2:{serverHandle}:{itemHandle}"); WriteServerHandle = serverHandle; WriteItemHandle = itemHandle; WriteCurrentUserId = currentUserId; WriteVerifierUserId = verifierUserId; WriteValue = value; WriteTimestamp = timestamp; WriteThreadId = Environment.CurrentManagedThreadId; ThrowIfWriteFailureConfigured(itemHandle); OnWriteCallback?.Invoke(); } private void ThrowIfWriteFailureConfigured(int itemHandle) { // Per-item write-failure injection — used by the bulk-write tests to // exercise the "one bad entry surfaces as was_successful=false but // the loop keeps going" contract on BulkWriteResult. if (writeExceptionByItemHandle.TryGetValue(itemHandle, out Exception? exception)) { throw exception; } } /// Gets the server handle passed to Suspend, if called. public int? SuspendServerHandle { get; private set; } /// Gets the item handle passed to Suspend, if called. public int? SuspendItemHandle { get; private set; } /// Gets the thread ID on which Suspend was called. public int? SuspendThreadId { get; private set; } /// Gets the server handle passed to Activate, if called. public int? ActivateServerHandle { get; private set; } /// Gets the item handle passed to Activate, if called. public int? ActivateItemHandle { get; private set; } /// Gets the thread ID on which Activate was called. public int? ActivateThreadId { get; private set; } /// Gets the server handle passed to AuthenticateUser, if called. public int? AuthenticateServerHandle { get; private set; } /// Gets the user name passed to AuthenticateUser, if called. public string? AuthenticateUserName { get; private set; } /// Gets the credential passed to AuthenticateUser, if called. Used only to prove non-logging. public string? AuthenticatePassword { get; private set; } /// Gets the thread ID on which AuthenticateUser was called. public int? AuthenticateThreadId { get; private set; } /// Gets the server handle passed to ArchestrAUserToId, if called. public int? ArchestrAUserToIdServerHandle { get; private set; } /// Gets the GUID passed to ArchestrAUserToId, if called. public string? ArchestrAUserToIdGuid { get; private set; } /// Gets the server handle passed to AddBufferedItem, if called. public int? AddBufferedItemServerHandle { get; private set; } /// Gets the item definition passed to AddBufferedItem, if called. public string? AddBufferedItemDefinition { get; private set; } /// Gets the item context passed to AddBufferedItem, if called. public string? AddBufferedItemContext { get; private set; } /// Gets the server handle passed to SetBufferedUpdateInterval, if called. public int? SetBufferedUpdateIntervalServerHandle { get; private set; } /// Gets the interval passed to SetBufferedUpdateInterval, if called. public int? SetBufferedUpdateIntervalValue { get; private set; } /// public object Suspend(int serverHandle, int itemHandle) { operationNames.Add($"Suspend:{serverHandle}:{itemHandle}"); SuspendServerHandle = serverHandle; SuspendItemHandle = itemHandle; SuspendThreadId = Environment.CurrentManagedThreadId; return new FakeMxStatus { success = 1, category = 0, detectedBy = 0, detail = 0 }; } /// public object Activate(int serverHandle, int itemHandle) { operationNames.Add($"Activate:{serverHandle}:{itemHandle}"); ActivateServerHandle = serverHandle; ActivateItemHandle = itemHandle; ActivateThreadId = Environment.CurrentManagedThreadId; return new FakeMxStatus { success = 1, category = 0, detectedBy = 0, detail = 0 }; } /// public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword) { // Deliberately does NOT include the password in the operation log. operationNames.Add($"AuthenticateUser:{serverHandle}:{verifyUser}"); AuthenticateServerHandle = serverHandle; AuthenticateUserName = verifyUser; AuthenticatePassword = verifyUserPassword; AuthenticateThreadId = Environment.CurrentManagedThreadId; return 1; } /// public int ArchestrAUserToId(int serverHandle, string userIdGuid) { operationNames.Add($"ArchestrAUserToId:{serverHandle}:{userIdGuid}"); ArchestrAUserToIdServerHandle = serverHandle; ArchestrAUserToIdGuid = userIdGuid; return 7; } /// public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) { operationNames.Add($"AddBufferedItem:{serverHandle}:{itemDefinition}:{itemContext}"); AddBufferedItemServerHandle = serverHandle; AddBufferedItemDefinition = itemDefinition; AddBufferedItemContext = itemContext; return 1; } /// public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) { operationNames.Add($"SetBufferedUpdateInterval:{serverHandle}:{updateIntervalMilliseconds}"); SetBufferedUpdateIntervalServerHandle = serverHandle; SetBufferedUpdateIntervalValue = updateIntervalMilliseconds; } } /// Factory for creating fake MXAccess COM objects in tests. private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory { /// Initializes a new instance of the FakeMxAccessComObjectFactory class. /// The fake COM object to return from Create. public FakeMxAccessComObjectFactory(FakeMxAccessComObject fakeComObject) { FakeComObject = fakeComObject; } /// Gets the fake COM object. public FakeMxAccessComObject FakeComObject { get; } /// public object Create() { return FakeComObject; } } }