using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.MxGateway.Tests.Gateway.Workers.Fakes;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers;
public sealed class FakeWorkerHarnessTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// Verifies that completing startup with hello and ready transitions the client to ready state.
/// A task that represents the asynchronous operation.
[Fact]
public async Task CompleteStartupAsync_WithHelloAndReady_TransitionsClientToReady()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
Task startTask = client.StartAsync(CancellationToken.None);
WorkerEnvelope gatewayHello = await fakeWorker.CompleteStartupAsync();
await startTask.WaitAsync(TestTimeout);
Assert.Equal(WorkerEnvelope.BodyOneofCase.GatewayHello, gatewayHello.BodyCase);
Assert.Equal(FakeWorkerHarness.DefaultNonce, gatewayHello.GatewayHello.Nonce);
Assert.Equal(WorkerClientState.Ready, client.State);
Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, client.ProcessId);
}
/// Verifies that a protocol version mismatch during startup fails the client.
/// A task that represents the asynchronous operation.
[Fact]
public async Task StartAsync_WithProtocolMismatch_FailsStartup()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
Task startTask = client.StartAsync(CancellationToken.None);
WorkerEnvelope gatewayHello = await fakeWorker.ReadGatewayEnvelopeAsync();
Assert.Equal(WorkerEnvelope.BodyOneofCase.GatewayHello, gatewayHello.BodyCase);
await fakeWorker.SendWorkerHelloAsync(
workerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion + 1);
WorkerClientException exception = await Assert.ThrowsAsync(
async () => await startTask.WaitAsync(TestTimeout));
Assert.Equal(WorkerClientErrorCode.ProtocolViolation, exception.ErrorCode);
}
/// Verifies that a scripted reply completes a pending command invocation.
/// A task that represents the asynchronous operation.
[Fact]
public async Task InvokeAsync_WithScriptedReply_CompletesCommand()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.Ping),
TestTimeout,
CancellationToken.None);
WorkerEnvelope commandEnvelope = await fakeWorker.ReadCommandAsync();
await fakeWorker.ReplyToCommandAsync(commandEnvelope);
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(commandEnvelope.CorrelationId, reply.Reply.CorrelationId);
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
}
/// Verifies that scripted events are yielded in order through the event stream.
/// A task that represents the asynchronous operation.
[Fact]
public async Task ReadEventsAsync_WithScriptedEvents_YieldsOrderedEvents()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
await using IAsyncEnumerator events =
client.ReadEventsAsync(cancellationTokenSource.Token).GetAsyncEnumerator(cancellationTokenSource.Token);
await fakeWorker.EmitEventAsync(MxEventFamily.OnDataChange, cancellationTokenSource.Token);
await fakeWorker.EmitEventAsync(MxEventFamily.OperationComplete, cancellationTokenSource.Token);
Assert.True(await events.MoveNextAsync());
Assert.Equal((ulong)3, events.Current.Event.WorkerSequence);
Assert.Equal(MxEventFamily.OnDataChange, events.Current.Event.Family);
Assert.True(await events.MoveNextAsync());
Assert.Equal((ulong)4, events.Current.Event.WorkerSequence);
Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family);
}
/// Verifies that a scripted fault from the worker faults the client.
/// A task that represents the asynchronous operation.
[Fact]
public async Task ReadLoop_WithScriptedFault_FaultsClient()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
await fakeWorker.EmitFaultAsync(
WorkerFaultCategory.MxaccessCommandFailed,
"scripted MXAccess command fault");
await WaitUntilAsync(
() => client.State == WorkerClientState.Faulted,
TestTimeout);
Assert.Equal(WorkerClientState.Faulted, client.State);
}
///
/// Verifies that sending a heartbeat updates the client heartbeat state. Uses a
/// so the timestamp advance is deterministic rather
/// than relying on a wall-clock Task.Delay exceeding clock resolution.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task SendHeartbeatAsync_UpdatesClientHeartbeatState()
{
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-18T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient(timeProvider: clock);
await StartClientAsync(fakeWorker, client);
DateTimeOffset previousHeartbeat = client.LastHeartbeatAt;
clock.Advance(TimeSpan.FromSeconds(1));
await fakeWorker.SendHeartbeatAsync(
configureHeartbeat: heartbeat => heartbeat.WorkerProcessId = 2468);
await WaitUntilAsync(
() => client.ProcessId == 2468 && client.LastHeartbeatAt > previousHeartbeat,
TestTimeout);
Assert.Equal(WorkerClientState.Ready, client.State);
Assert.Equal(previousHeartbeat + TimeSpan.FromSeconds(1), client.LastHeartbeatAt);
}
/// Verifies that a hung worker times out pending command invocations.
/// A task that represents the asynchronous operation.
[Fact]
public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.Ping),
TimeSpan.FromMilliseconds(50),
CancellationToken.None);
WorkerEnvelope commandEnvelope = await fakeWorker.ReadCommandAsync();
WorkerClientException exception = await Assert.ThrowsAsync(
async () => await invokeTask.WaitAsync(TestTimeout));
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase);
Assert.Equal(WorkerClientErrorCode.CommandTimeout, exception.ErrorCode);
}
/// Verifies that a malformed frame in the read loop faults the client.
/// A task that represents the asynchronous operation.
[Fact]
public async Task ReadLoop_WithMalformedFrame_FaultsClient()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
await fakeWorker.WriteMalformedPayloadAsync(new byte[] { 0x08, 0x96, 0x01 });
await WaitUntilAsync(
() => client.State == WorkerClientState.Faulted,
TestTimeout);
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// Verifies that a shutdown acknowledgment from the worker closes the client.
/// A task that represents the asynchronous operation.
[Fact]
public async Task ShutdownAsync_WithShutdownAck_ClosesClient()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task shutdownTask = client.ShutdownAsync(TestTimeout, CancellationToken.None);
WorkerEnvelope shutdownEnvelope = await fakeWorker.ReadShutdownAsync();
await fakeWorker.SendShutdownAckAsync();
await shutdownTask.WaitAsync(TestTimeout);
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdown, shutdownEnvelope.BodyCase);
Assert.Equal(WorkerClientState.Closed, client.State);
}
///
/// Verifies that RespondToControlCommandAsync echoes the Ping message back
/// in the DiagnosticMessage field, matching the real worker's ping reply shape.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task RespondToControlCommandAsync_Ping_EchoesMessageInDiagnostic()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.Ping, cmd => cmd.Ping = new PingCommand { Message = "hello-ping" }),
TestTimeout,
CancellationToken.None);
await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout);
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
Assert.Equal("hello-ping", reply.Reply.DiagnosticMessage);
}
///
/// Verifies that RespondToControlCommandAsync returns a SessionStateReply
/// with state Ready for a GetSessionState command.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task RespondToControlCommandAsync_GetSessionState_ReturnsReadyState()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.GetSessionState),
TestTimeout,
CancellationToken.None);
await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout);
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.GetSessionState, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
Assert.NotNull(reply.Reply.SessionState);
Assert.Equal(SessionState.Ready, reply.Reply.SessionState.State);
}
///
/// Verifies that RespondToControlCommandAsync returns a WorkerInfoReply
/// with the fake worker's process ID, version, and MXAccess identifiers.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task RespondToControlCommandAsync_GetWorkerInfo_ReturnsFakeWorkerInfo()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.GetWorkerInfo),
TestTimeout,
CancellationToken.None);
await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout);
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.GetWorkerInfo, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
Assert.NotNull(reply.Reply.WorkerInfo);
Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, reply.Reply.WorkerInfo.WorkerProcessId);
Assert.Equal("LMXProxy.LMXProxyServer.1", reply.Reply.WorkerInfo.MxaccessProgid);
Assert.Equal("{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}", reply.Reply.WorkerInfo.MxaccessClsid);
Assert.Equal("fake-worker", reply.Reply.WorkerInfo.WorkerVersion);
}
///
/// Verifies that RespondToControlCommandAsync returns an empty DrainEventsReply
/// for a DrainEvents command (the fake harness has no queued events).
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task RespondToControlCommandAsync_DrainEvents_ReturnsEmptyReply()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.DrainEvents, cmd => cmd.DrainEvents = new DrainEventsCommand { MaxEvents = 32 }),
TestTimeout,
CancellationToken.None);
await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout);
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.DrainEvents, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
Assert.NotNull(reply.Reply.DrainEvents);
Assert.Empty(reply.Reply.DrainEvents.Events);
}
///
/// Verifies that RespondToControlCommandAsync for ShutdownWorker sends an OK
/// reply followed by a WorkerShutdownAck, which closes the client.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task RespondToControlCommandAsync_ShutdownWorker_SendsReplyThenAck()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
// ShutdownAsync triggers a WorkerShutdown envelope (not WorkerCommand),
// so we directly invoke ShutdownWorker as a control command via InvokeAsync.
Task invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.ShutdownWorker, cmd => cmd.ShutdownWorker = new ShutdownWorkerCommand()),
TestTimeout,
CancellationToken.None);
// The harness reads the ShutdownWorker WorkerCommand and replies with
// OK + ShutdownAck — the WorkerClient's read loop processes the ack and
// transitions to Closed.
await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout);
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.ShutdownWorker, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
await WaitUntilAsync(() => client.State == WorkerClientState.Closed, TestTimeout);
Assert.Equal(WorkerClientState.Closed, client.State);
}
private static async Task StartClientAsync(
FakeWorkerHarness fakeWorker,
WorkerClient client)
{
Task startTask = client.StartAsync(CancellationToken.None);
await fakeWorker.CompleteStartupAsync().ConfigureAwait(false);
await startTask.WaitAsync(TestTimeout).ConfigureAwait(false);
}
private static WorkerCommand CreateCommand(
MxCommandKind kind,
Action? configure = null)
{
MxCommand command = new() { Kind = kind };
configure?.Invoke(command);
return new WorkerCommand { Command = command };
}
private static async Task WaitUntilAsync(
Func predicate,
TimeSpan timeout)
{
using CancellationTokenSource cancellationTokenSource = new(timeout);
while (!predicate())
{
await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationTokenSource.Token);
}
}
}