Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs
T
Joseph Doherty 9871d4772d perf(worker): value cache borrows the write-once event's instances — three clones per OnDataChange removed
MxAccessValueCache.Set deep-copied the Value (recursive for an MxArray), the
SourceTimestamp, and the Statuses RepeatedField (container plus every
MxStatusProxy) on every OnDataChange. The aliasing audit found all three
removable: the sink enqueues the event first — which stamps
WorkerSequence/WorkerTimestamp inside the queue lock — and only then runs the
postPublish hook that reaches Set, so the event is write-once by then and the
queue's ownership invariant forbids later mutation. The producer never reuses
instances (fresh MxEvent per mapper call, fresh MxValue per convert), and the
alias already existed on the read side: MxAccessSession.SucceededRead puts the
cache's own Value/SourceTimestamp/status references on every BulkReadResult,
which the worker only serializes onto the IPC pipe.

Set and CachedValue now carry the ownership contract: the cache holds borrowed
references into an enqueued, write-once MxEvent; consumers may read and
serialize, never mutate. Mutation would corrupt the still-queued event AND
invalidate QueuedEvent.Size — the enqueue-time memoized serialized size the
byte-budgeted Drain charges — so a grown message could overshoot the negotiated
frame max and fault the session with MessageTooLarge. MxAccessEventQueue's
class remark, which claimed the cache keeps an independent snapshot, is
corrected to point at the borrow.

MxAccessWriteCompletionCache.Record keeps its parallel statuses.Clone()
deliberately, with a cross-reference explaining why: it takes a bare
RepeatedField whose provenance its signature cannot constrain, and it is on the
command-rate write path, not the streaming hot path.

Tests: Set_StoresIndependentSnapshot_UnaffectedByLaterEventMutation codified
the invariant being reversed, so it is replaced by
Set_BorrowsTheEventsOwnInstances_ByOwnershipContract (Assert.Same on Value,
SourceTimestamp, and the status row). Adds the missing cached-read-path test to
MxAccessCommandExecutorTests — nothing in the worker exercised was_cached ==
true end to end — asserting the cache hit, reference identity out to the
BulkReadResult, and that no COM call is made for the read.

Not built or tested here: these are net48/x86 worker files that cannot compile
on the macOS tree. Verification is deferred to the windev gate.
2026-08-15 21:03:35 -04:00

2686 lines
117 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 ReadBulk's cached fast path end to end — the
/// <c>was_cached = true</c> half of the command, which nothing else in
/// the worker suite exercises. With the tag already added AND advised
/// and a value in the per-session cache, the executor answers from the
/// cache: the result is successful and flagged cached, and no COM call
/// is made for the read at all, so the subscription the caller did not
/// create is left exactly as it was.
///
/// It also pins the borrow contract through the whole read path: the
/// <c>Value</c>, <c>SourceTimestamp</c>, and status row on the
/// <c>BulkReadResult</c> are the cached event's own instances, not
/// copies (see <see cref="MxAccessValueCache.Set"/>). The worker only
/// serializes them onto the IPC pipe, so the alias never escapes the
/// process.
///
/// Driven through <see cref="MxAccessCommandExecutor"/> directly rather
/// than <see cref="MxAccessStaSession"/> because seeding the cache needs
/// a handle on it: <see cref="MxAccessSession.Create"/> only shares the
/// sink's cache for the production <c>MxAccessBaseEventSink</c>, which
/// casts the COM object to <c>LMXProxyServerClass</c> and so cannot take
/// a fake, while <c>CreateForTesting</c> accepts the cache directly. The
/// cached path neither waits nor pumps, so it needs no STA.
/// </summary>
[Fact]
public void Execute_ReadBulk_WhenTagIsAdvisedAndCached_ServesTheCachedInstancesWithoutTouchingTheSubscription()
{
FakeMxAccessComObject fakeComObject = new(
registerHandle: 92,
addItemHandle: 920);
MxAccessValueCache valueCache = new();
using MxAccessSession session = MxAccessSession.CreateForTesting(
mxAccessServer: fakeComObject,
eventSink: new NoopEventSink(),
valueCache: valueCache);
MxAccessCommandExecutor executor = new(
session,
new ZB.MOM.WW.MxGateway.Worker.Conversion.VariantConverter());
// Registry half of the fast path: the tag must resolve to a live item
// handle on this server AND carry an advice, or TryGetCachedReadFor
// falls through to the AddItem/Advise snapshot lifecycle.
MxCommandReply registerReply = executor.Execute(
CreateRegisterCommand("register-before-cached-read", "client-a"));
MxCommandReply addItemReply = executor.Execute(
CreateAddItemCommand("add-before-cached-read", 92, "Galaxy.Tag.Value"));
MxCommandReply adviseReply = executor.Execute(
CreateAdviseCommand("advise-before-cached-read", 92, 920));
Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code);
// Cache half: stand in for the event sink's post-publish hook, which
// records the event it just enqueued.
MxEvent cachedEvent = new()
{
Family = MxEventFamily.OnDataChange,
ServerHandle = 92,
ItemHandle = 920,
Quality = 192,
SourceTimestamp = Timestamp.FromDateTime(new(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc)),
Value = new MxValue
{
DataType = MxDataType.Integer,
VariantType = "VT_I4",
Int32Value = 7788,
},
OnDataChange = new OnDataChangeEvent(),
};
cachedEvent.Statuses.Add(new MxStatusProxy { Category = MxStatusCategory.Ok });
valueCache.Set(92, 920, cachedEvent);
MxCommandReply reply = executor.Execute(CreateReadBulkCommand(
"read-bulk-cached",
serverHandle: 92,
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.True(result.WasSuccessful);
Assert.True(result.WasCached);
Assert.Equal("Galaxy.Tag.Value", result.TagAddress);
Assert.Equal(920, result.ItemHandle);
Assert.Equal(192, result.Quality);
Assert.Equal(7788, result.Value.Int32Value);
// Borrowed, not copied — all the way from the event handed to
// MxAccessValueCache.Set out to the reply the worker serializes.
Assert.Same(cachedEvent.Value, result.Value);
Assert.Same(cachedEvent.SourceTimestamp, result.SourceTimestamp);
Assert.Same(cachedEvent.Statuses[0], Assert.Single(result.Statuses));
// No second AddItem, and above all no UnAdvise/RemoveItem: only the
// three setup calls ever reached MXAccess.
Assert.Equal(
new[] { "Register:client-a", "AddItem:92:Galaxy.Tag.Value", "Advise:92:920" },
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;
}
}
}