Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs
T
Joseph Doherty b0e65d4f31 feat(worker): correlate OnWriteComplete onto plain Write/Write2 replies (06/S-1 follow-up)
OtOpcUa's dominant FreeAccess write path goes out as MX_COMMAND_KIND_WRITE,
not WriteSecured — the original 06/S-1 brief mis-scoped the correlation, so
a refused plain write was invisible on the unary reply (verified live on
windev 2026-08-09). ExecuteWrite/ExecuteWrite2 now use the same pre-call
version baseline + bounded pump-wait as the secured kinds. Bulk writes stay
fire-and-forget.
2026-08-09 19:47:00 -04:00

2585 lines
112 KiB
C#

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
{
/// <summary>Verifies that Register command calls MXAccess on the STA thread and preserves the server handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that Unregister command calls MXAccess on the STA thread and removes the tracked server handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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());
}
/// <summary>Verifies that Unregister preserves the HResult when MXAccess throws and does not rewrite the failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that AddItem command calls MXAccess on the STA thread and tracks the item handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that AddItem2 command passes the context exactly and tracks the item handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that RemoveItem command calls MXAccess on the STA thread and removes the tracked item handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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());
}
/// <summary>Verifies that RemoveItem removes tracked advice after MXAccess succeeds on an advised handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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());
}
/// <summary>Verifies that RemoveItem preserves the HResult and keeps the tracked item handle when using a cross-server handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that AddItem2 preserves the HResult when MXAccess throws and does not track the item handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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());
}
/// <summary>Verifies that Advise command calls MXAccess on the STA thread and tracks plain advice.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that AdviseSupervisory calls a distinct MXAccess method and tracks supervisory advice.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that UnAdvise command calls MXAccess on the STA thread and removes the tracked advice.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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());
}
/// <summary>Verifies that Advise preserves the HResult when MXAccess throws and does not track the advice.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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());
}
/// <summary>Verifies that UnAdvise preserves the HResult when MXAccess throws and keeps the tracked advice.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that SubscribeBulk runs sequential MXAccess calls and returns per-item results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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 <c>WasSuccessful=false</c> with the underlying HRESULT.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DispatchAsync_WriteBulk_RunsSequentialWritesAndReturnsPerEntryResults()
{
const int hresult = unchecked((int)0x80070057);
FakeMxAccessComObject fakeComObject = new(
registerHandle: 80,
writeExceptionByItemHandle: new Dictionary<int, Exception>
{
[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);
}
/// <summary>Verifies that Write2Bulk forwards value AND timestamp to each per-entry Write2.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that WriteSecuredBulk forwards both user ids per entry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that WriteSecured2Bulk forwards user ids, value, and timestamp per entry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that ReadBulk with no payload returns an invalid request error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that UnsubscribeBulk removes items after UnAdvise failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that ShutdownGracefullyAsync cleans up handles in advice, item, server order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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)));
}
/// <summary>Verifies that ShutdownGracefullyAsync records cleanup failures and continues.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that Register without payload returns an invalid request error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that AddItem without payload returns an invalid request error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that Advise without payload returns an invalid request error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that Write dispatches the converted value to MXAccess on the STA thread.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that Write2 forwards the converted value and timestamp to MXAccess.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that WriteSecured forwards the operator and verifier user ids to MXAccess.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that WriteSecured2 forwards user ids, value, and timestamp to MXAccess.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<MxCommandReply> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// Verifies that WriteSecured2 correlates the same way as WriteSecured
/// (fast-completion edge).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// Verifies Write2 correlates the same way as Write (fast-completion
/// edge).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<MxCommandReply> 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<MxStatusProxy> CreateCompletionRows(int detail)
{
return new Google.Protobuf.Collections.RepeatedField<MxStatusProxy>
{
new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
Detail = detail,
},
};
}
/// <summary>Verifies that Write without a payload returns an invalid request error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that Write without a value returns an invalid request error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies Suspend calls MXAccess on the STA and maps the native status to MxStatusProxy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies Activate calls MXAccess on the STA and maps the native status to MxStatusProxy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies AuthenticateUser passes credentials to MXAccess on the STA and returns the user id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>Verifies ArchestrAUserToId calls MXAccess on the STA and returns the resolved user id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies AddBufferedItem calls MXAccess on the STA and tracks the buffered item handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies SetBufferedUpdateInterval calls MXAccess on the STA and returns a base OK reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// Verifies that a command with an unknown <see cref="MxCommandKind"/> value returns an
/// <see cref="ProtocolStatusCode.InvalidRequest"/> reply whose diagnostic contains "Unsupported".
/// This pins the <c>_ =&gt; CreateInvalidRequestReply(...)</c> discard arm in
/// <c>MxAccessCommandExecutor.Execute</c>: a regression that changed the arm to
/// <c>throw</c> would propagate an unhandled exception through <c>WorkerPipeSession</c>
/// and no other test would catch it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<string> 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<int> 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<string> 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));
}
/// <summary>
/// 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
/// <see cref="MxAccessSession.Create"/> shares this cache with the write
/// executor, letting tests record completions the executor's bounded
/// wait then observes.
/// </summary>
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<int, Exception> writeExceptionByItemHandle;
private readonly List<string> operationNames = new();
/// <summary>
/// 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.
/// </summary>
public Action? OnWriteCallback { get; set; }
/// <summary>Initializes a fake MXAccess COM object with the given handles and optional exceptions.</summary>
/// <param name="registerHandle">Return value for Register method.</param>
/// <param name="addItemHandle">Return value for AddItem method.</param>
/// <param name="addItem2Handle">Return value for AddItem2 method.</param>
/// <param name="unregisterException">Exception to throw from Unregister, if any.</param>
/// <param name="addItemException">Exception to throw from AddItem, if any.</param>
/// <param name="addItem2Exception">Exception to throw from AddItem2, if any.</param>
/// <param name="removeItemException">Exception to throw from RemoveItem, if any.</param>
/// <param name="adviseException">Exception to throw from Advise, if any.</param>
/// <param name="unAdviseException">Exception to throw from UnAdvise, if any.</param>
/// <param name="adviseSupervisoryException">Exception to throw from AdviseSupervisory, if any.</param>
/// <param name="writeExceptionByItemHandle">Map of item handles to exceptions thrown on write.</param>
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<int, Exception>? 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<int, Exception>();
}
/// <summary>Gets the client name passed to Register, if called.</summary>
public string? RegisteredClientName { get; private set; }
/// <summary>Gets the thread ID on which Register was called.</summary>
public int? RegisterThreadId { get; private set; }
/// <summary>Gets the server handle passed to Unregister, if called.</summary>
public int? UnregisteredServerHandle { get; private set; }
/// <summary>Gets the thread ID on which Unregister was called.</summary>
public int? UnregisterThreadId { get; private set; }
/// <summary>Gets the server handle passed to AddItem, if called.</summary>
public int? AddItemServerHandle { get; private set; }
/// <summary>Gets the item definition passed to AddItem, if called.</summary>
public string? AddItemDefinition { get; private set; }
/// <summary>Gets the thread ID on which AddItem was called.</summary>
public int? AddItemThreadId { get; private set; }
/// <summary>Gets the server handle passed to AddItem2, if called.</summary>
public int? AddItem2ServerHandle { get; private set; }
/// <summary>Gets the item definition passed to AddItem2, if called.</summary>
public string? AddItem2Definition { get; private set; }
/// <summary>Gets the item context passed to AddItem2, if called.</summary>
public string? AddItem2Context { get; private set; }
/// <summary>Gets the thread ID on which AddItem2 was called.</summary>
public int? AddItem2ThreadId { get; private set; }
/// <summary>Gets the server handle passed to RemoveItem, if called.</summary>
public int? RemoveItemServerHandle { get; private set; }
/// <summary>Gets the item handle passed to RemoveItem, if called.</summary>
public int? RemovedItemHandle { get; private set; }
/// <summary>Gets the thread ID on which RemoveItem was called.</summary>
public int? RemoveItemThreadId { get; private set; }
/// <summary>Gets the server handle passed to Advise, if called.</summary>
public int? AdviseServerHandle { get; private set; }
/// <summary>Gets the item handle passed to Advise, if called.</summary>
public int? AdvisedItemHandle { get; private set; }
/// <summary>Gets the thread ID on which Advise was called.</summary>
public int? AdviseThreadId { get; private set; }
/// <summary>Gets the server handle passed to UnAdvise, if called.</summary>
public int? UnAdviseServerHandle { get; private set; }
/// <summary>Gets the item handle passed to UnAdvise, if called.</summary>
public int? UnAdvisedItemHandle { get; private set; }
/// <summary>Gets the thread ID on which UnAdvise was called.</summary>
public int? UnAdviseThreadId { get; private set; }
/// <summary>Gets the server handle passed to AdviseSupervisory, if called.</summary>
public int? AdviseSupervisoryServerHandle { get; private set; }
/// <summary>Gets the item handle passed to AdviseSupervisory, if called.</summary>
public int? AdviseSupervisoryItemHandle { get; private set; }
/// <summary>Gets the thread ID on which AdviseSupervisory was called.</summary>
public int? AdviseSupervisoryThreadId { get; private set; }
/// <summary>Gets the list of operations performed on this fake object.</summary>
public IReadOnlyList<string> OperationNames => operationNames.ToArray();
/// <inheritdoc />
public int Register(string clientName)
{
operationNames.Add($"Register:{clientName}");
RegisteredClientName = clientName;
RegisterThreadId = Environment.CurrentManagedThreadId;
return registerHandle;
}
/// <inheritdoc />
public void Unregister(int serverHandle)
{
operationNames.Add($"Unregister:{serverHandle}");
UnregisteredServerHandle = serverHandle;
UnregisterThreadId = Environment.CurrentManagedThreadId;
if (unregisterException is not null)
{
throw unregisterException;
}
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
/// <summary>Gets the server handle passed to the most recent write, if called.</summary>
public int? WriteServerHandle { get; private set; }
/// <summary>Gets the item handle passed to the most recent write, if called.</summary>
public int? WriteItemHandle { get; private set; }
/// <summary>Gets the value passed to the most recent write, if called.</summary>
public object? WriteValue { get; private set; }
/// <summary>Gets the timestamp passed to the most recent timestamped write, if called.</summary>
public object? WriteTimestamp { get; private set; }
/// <summary>Gets the user id passed to the most recent Write/Write2, if called.</summary>
public int? WriteUserId { get; private set; }
/// <summary>Gets the current user id passed to the most recent secured write, if called.</summary>
public int? WriteCurrentUserId { get; private set; }
/// <summary>Gets the verifier user id passed to the most recent secured write, if called.</summary>
public int? WriteVerifierUserId { get; private set; }
/// <summary>Gets the thread ID on which the most recent write was called.</summary>
public int? WriteThreadId { get; private set; }
/// <inheritdoc />
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();
}
/// <inheritdoc />
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();
}
/// <inheritdoc />
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();
}
/// <inheritdoc />
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;
}
}
/// <summary>Gets the server handle passed to Suspend, if called.</summary>
public int? SuspendServerHandle { get; private set; }
/// <summary>Gets the item handle passed to Suspend, if called.</summary>
public int? SuspendItemHandle { get; private set; }
/// <summary>Gets the thread ID on which Suspend was called.</summary>
public int? SuspendThreadId { get; private set; }
/// <summary>Gets the server handle passed to Activate, if called.</summary>
public int? ActivateServerHandle { get; private set; }
/// <summary>Gets the item handle passed to Activate, if called.</summary>
public int? ActivateItemHandle { get; private set; }
/// <summary>Gets the thread ID on which Activate was called.</summary>
public int? ActivateThreadId { get; private set; }
/// <summary>Gets the server handle passed to AuthenticateUser, if called.</summary>
public int? AuthenticateServerHandle { get; private set; }
/// <summary>Gets the user name passed to AuthenticateUser, if called.</summary>
public string? AuthenticateUserName { get; private set; }
/// <summary>Gets the credential passed to AuthenticateUser, if called. Used only to prove non-logging.</summary>
public string? AuthenticatePassword { get; private set; }
/// <summary>Gets the thread ID on which AuthenticateUser was called.</summary>
public int? AuthenticateThreadId { get; private set; }
/// <summary>Gets the server handle passed to ArchestrAUserToId, if called.</summary>
public int? ArchestrAUserToIdServerHandle { get; private set; }
/// <summary>Gets the GUID passed to ArchestrAUserToId, if called.</summary>
public string? ArchestrAUserToIdGuid { get; private set; }
/// <summary>Gets the server handle passed to AddBufferedItem, if called.</summary>
public int? AddBufferedItemServerHandle { get; private set; }
/// <summary>Gets the item definition passed to AddBufferedItem, if called.</summary>
public string? AddBufferedItemDefinition { get; private set; }
/// <summary>Gets the item context passed to AddBufferedItem, if called.</summary>
public string? AddBufferedItemContext { get; private set; }
/// <summary>Gets the server handle passed to SetBufferedUpdateInterval, if called.</summary>
public int? SetBufferedUpdateIntervalServerHandle { get; private set; }
/// <summary>Gets the interval passed to SetBufferedUpdateInterval, if called.</summary>
public int? SetBufferedUpdateIntervalValue { get; private set; }
/// <inheritdoc />
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 };
}
/// <inheritdoc />
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 };
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public int ArchestrAUserToId(int serverHandle, string userIdGuid)
{
operationNames.Add($"ArchestrAUserToId:{serverHandle}:{userIdGuid}");
ArchestrAUserToIdServerHandle = serverHandle;
ArchestrAUserToIdGuid = userIdGuid;
return 7;
}
/// <inheritdoc />
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext)
{
operationNames.Add($"AddBufferedItem:{serverHandle}:{itemDefinition}:{itemContext}");
AddBufferedItemServerHandle = serverHandle;
AddBufferedItemDefinition = itemDefinition;
AddBufferedItemContext = itemContext;
return 1;
}
/// <inheritdoc />
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds)
{
operationNames.Add($"SetBufferedUpdateInterval:{serverHandle}:{updateIntervalMilliseconds}");
SetBufferedUpdateIntervalServerHandle = serverHandle;
SetBufferedUpdateIntervalValue = updateIntervalMilliseconds;
}
}
/// <summary>Factory for creating fake MXAccess COM objects in tests.</summary>
private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory
{
/// <summary>Initializes a new instance of the FakeMxAccessComObjectFactory class.</summary>
/// <param name="fakeComObject">The fake COM object to return from Create.</param>
public FakeMxAccessComObjectFactory(FakeMxAccessComObject fakeComObject)
{
FakeComObject = fakeComObject;
}
/// <summary>Gets the fake COM object.</summary>
public FakeMxAccessComObject FakeComObject { get; }
/// <inheritdoc />
public object Create()
{
return FakeComObject;
}
}
}