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); } } }