Add XML documentation across gateway, worker, and .NET client

This commit is contained in:
Joseph Doherty
2026-04-30 11:49:58 -04:00
parent 4731ab535c
commit eed1e88a37
269 changed files with 4555 additions and 13 deletions
@@ -7,8 +7,14 @@ using MxGateway.Server.Security.Authorization;
namespace MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Tests for dashboard authentication using API keys.
/// </summary>
public sealed class DashboardAuthenticatorTests
{
/// <summary>
/// Verifies an admin-scoped key produces a valid cookie principal.
/// </summary>
[Fact]
public async Task AuthenticateAsync_AdminKey_ReturnsCookiePrincipal()
{
@@ -29,6 +35,9 @@ public sealed class DashboardAuthenticatorTests
Assert.Equal("Bearer mxgw_operator01_super-secret", verifier.LastAuthorizationHeader);
}
/// <summary>
/// Verifies a non-admin key fails authentication without exposing the API key.
/// </summary>
[Fact]
public async Task AuthenticateAsync_NonAdminKey_ReturnsFailureWithoutRawApiKey()
{
@@ -44,6 +53,9 @@ public sealed class DashboardAuthenticatorTests
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
}
/// <summary>
/// Verifies that when admin scope is not required, any authenticated key is accepted.
/// </summary>
[Fact]
public async Task AuthenticateAsync_RequireAdminScopeFalse_AllowsAuthenticatedKey()
{
@@ -59,6 +71,9 @@ public sealed class DashboardAuthenticatorTests
Assert.NotNull(result.Principal);
}
/// <summary>
/// Verifies an invalid key returns a generic failure message.
/// </summary>
[Fact]
public async Task AuthenticateAsync_InvalidKey_ReturnsGenericFailure()
{
@@ -97,10 +112,17 @@ public sealed class DashboardAuthenticatorTests
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal)));
}
/// <summary>
/// Test implementation that records the authorization header for verification.
/// </summary>
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
{
/// <summary>
/// The authorization header that was last verified.
/// </summary>
public string? LastAuthorizationHeader { get; private set; }
/// <inheritdoc />
public Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
@@ -11,6 +11,7 @@ namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthorizationHandlerTests
{
/// <summary>Verifies that unauthenticated remote requests fail authorization.</summary>
[Fact]
public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed()
{
@@ -22,6 +23,7 @@ public sealed class DashboardAuthorizationHandlerTests
Assert.False(context.HasSucceeded);
}
/// <summary>Verifies that anonymous localhost access succeeds when allowed.</summary>
[Fact]
public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds()
{
@@ -33,6 +35,7 @@ public sealed class DashboardAuthorizationHandlerTests
Assert.True(context.HasSucceeded);
}
/// <summary>Verifies that authenticated users without admin scope fail authorization.</summary>
[Fact]
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed()
{
@@ -44,6 +47,7 @@ public sealed class DashboardAuthorizationHandlerTests
Assert.False(context.HasSucceeded);
}
/// <summary>Verifies that authenticated users with admin scope succeed.</summary>
[Fact]
public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds()
{
@@ -10,6 +10,7 @@ namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardCookieOptionsTests
{
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
[Fact]
public void Build_ConfiguresSecureDashboardCookie()
{
@@ -11,6 +11,9 @@ namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardSnapshotServiceTests
{
/// <summary>
/// Verifies snapshot returns empty collections and healthy status when registry is empty.
/// </summary>
[Fact]
public void GetSnapshot_WhenRegistryEmpty_ReturnsEmptyOperationalState()
{
@@ -27,6 +30,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.NotNull(snapshot.Configuration);
}
/// <summary>
/// Verifies snapshot projects active, faulted, and closed session states with worker and metrics data.
/// </summary>
[Fact]
public void GetSnapshot_ProjectsActiveAndFaultedSessionsWorkersMetricsAndFaults()
{
@@ -84,6 +90,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal("worker pipe disconnected", fault.Message);
}
/// <summary>
/// Verifies snapshot redacts sensitive values from client identity, session name, and fault messages.
/// </summary>
[Fact]
public void GetSnapshot_RedactsSecretsFromSessionAndFaultFields()
{
@@ -110,6 +119,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal("[redacted]", snapshot.Configuration.Authentication.PepperSecretName);
}
/// <summary>
/// Verifies snapshot generation does not mutate session or worker client state.
/// </summary>
[Fact]
public void GetSnapshot_DoesNotMutateSessionOrWorkerState()
{
@@ -136,6 +148,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(0, workerClient.KillCount);
}
/// <summary>
/// Verifies snapshot respects configured limits for recent sessions and faults.
/// </summary>
[Fact]
public void GetSnapshot_AppliesRecentSessionAndFaultLimits()
{
@@ -172,6 +187,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId);
}
/// <summary>
/// Verifies snapshot projects Galaxy hierarchy cache data including templates and categories.
/// </summary>
[Fact]
public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache()
{
@@ -217,6 +235,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
}
/// <summary>
/// Verifies snapshot watcher cancels cleanly when subscriber cancels.
/// </summary>
[Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{
@@ -268,10 +289,23 @@ public sealed class DashboardSnapshotServiceTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <summary>
/// Gets the current Galaxy hierarchy cache entry.
/// </summary>
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <summary>
/// Refreshes the cache asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// Waits for the first cache load asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -301,26 +335,59 @@ public sealed class DashboardSnapshotServiceTests
int? processId,
WorkerClientState state) : IWorkerClient
{
/// <summary>
/// Gets the session identifier.
/// </summary>
public string SessionId { get; } = sessionId;
/// <summary>
/// Gets the process identifier.
/// </summary>
public int? ProcessId { get; } = processId;
/// <summary>
/// Gets the current worker client state.
/// </summary>
public WorkerClientState State { get; private set; } = state;
/// <summary>
/// Gets the timestamp of the last heartbeat.
/// </summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z");
/// <summary>
/// Gets the count of start invocations.
/// </summary>
public int StartCount { get; private set; }
/// <summary>
/// Gets the count of shutdown invocations.
/// </summary>
public int ShutdownCount { get; private set; }
/// <summary>
/// Gets the count of kill invocations.
/// </summary>
public int KillCount { get; private set; }
/// <summary>
/// Starts the worker client asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
StartCount++;
return Task.CompletedTask;
}
/// <summary>
/// Invokes a worker command asynchronously.
/// </summary>
/// <param name="command">The command to invoke.</param>
/// <param name="timeout">Command timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Command reply.</returns>
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -329,6 +396,11 @@ public sealed class DashboardSnapshotServiceTests
return Task.FromResult(new WorkerCommandReply());
}
/// <summary>
/// Reads events from the worker asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of worker events.</returns>
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -336,6 +408,12 @@ public sealed class DashboardSnapshotServiceTests
yield break;
}
/// <summary>
/// Shuts down the worker client asynchronously.
/// </summary>
/// <param name="timeout">Shutdown timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
@@ -345,12 +423,20 @@ public sealed class DashboardSnapshotServiceTests
return Task.CompletedTask;
}
/// <summary>
/// Terminates the worker client.
/// </summary>
/// <param name="reason">Reason for termination.</param>
public void Kill(string reason)
{
KillCount++;
State = WorkerClientState.Faulted;
}
/// <summary>
/// Releases resources used by this worker client.
/// </summary>
/// <returns>Completed value task.</returns>
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
@@ -9,6 +9,7 @@ namespace MxGateway.Tests.Gateway;
public sealed class GatewayApplicationTests
{
/// <summary>Verifies that Build maps the live health check endpoint.</summary>
[Fact]
public void Build_MapsLiveHealthEndpoint()
{
@@ -23,6 +24,7 @@ public sealed class GatewayApplicationTests
Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName);
}
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
[Fact]
public void Build_RegistersGatewayMetrics()
{
@@ -33,6 +35,7 @@ public sealed class GatewayApplicationTests
Assert.NotNull(metrics);
}
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
[Fact]
public void Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
{
@@ -50,6 +53,7 @@ public sealed class GatewayApplicationTests
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
}
/// <summary>Verifies that Build does not map dashboard routes when the dashboard is disabled.</summary>
[Fact]
public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
{
@@ -64,6 +68,10 @@ public sealed class GatewayApplicationTests
StringComparison.Ordinal) == true);
}
/// <summary>Verifies that StartAsync fails when gateway configuration is invalid.</summary>
/// <param name="key">Configuration key to override.</param>
/// <param name="value">Invalid configuration value.</param>
/// <param name="expectedFailure">Expected validation error message.</param>
[Theory]
[InlineData(
"MxGateway:Worker:ExecutablePath",
@@ -21,6 +21,9 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private const int ServerHandle = 1001;
private const int ItemHandle = 2002;
/// <summary>
/// Verifies gateway session lifecycle with a scripted fake worker: open, command, event, close.
/// </summary>
[Fact]
public async Task GatewayService_WithFakeWorker_CompletesSessionCommandEventAndClosePath()
{
@@ -146,6 +149,10 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private readonly GatewayMetrics _metrics = new();
private readonly SessionRegistry _registry = new();
/// <summary>
/// Initializes a new instance of <see cref="GatewayServiceFixture"/>.
/// </summary>
/// <param name="launcher">Worker process launcher for the fixture.</param>
public GatewayServiceFixture(IWorkerProcessLauncher launcher)
{
IOptions<GatewayOptions> options = Options.Create(CreateOptions());
@@ -178,8 +185,14 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
NullLogger<MxAccessGatewayService>.Instance);
}
/// <summary>
/// Gets the configured gateway service instance.
/// </summary>
public MxAccessGatewayService Service { get; }
/// <summary>
/// Disposes all active sessions and metrics.
/// </summary>
public async ValueTask DisposeAsync()
{
foreach (GatewaySession session in _registry.Snapshot())
@@ -220,12 +233,27 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
public const int ProcessId = 4680;
private readonly ConcurrentQueue<MxCommandKind> _commandKinds = new();
/// <summary>
/// Gets the fake worker process instance.
/// </summary>
public FakeWorkerProcess Process { get; } = new(ProcessId);
/// <summary>
/// Gets the collection of command kinds processed by the worker.
/// </summary>
public IReadOnlyCollection<MxCommandKind> CommandKinds => _commandKinds.ToArray();
/// <summary>
/// Gets the worker's asynchronous task.
/// </summary>
public Task WorkerTask { get; private set; } = Task.CompletedTask;
/// <summary>
/// Launches a new worker process and returns a handle to manage it.
/// </summary>
/// <param name="request">Worker process launch request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Worker process handle.</returns>
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
@@ -321,12 +349,26 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
/// <summary>
/// Gets the process identifier.
/// </summary>
public int Id { get; } = processId;
/// <summary>
/// Gets a value indicating whether the process has exited.
/// </summary>
public bool HasExited { get; private set; }
/// <summary>
/// Gets the exit code of the process.
/// </summary>
public int? ExitCode { get; private set; }
/// <summary>
/// Waits for the process to exit asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
HasExited = true;
@@ -334,15 +376,26 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
return ValueTask.CompletedTask;
}
/// <summary>
/// Terminates the process.
/// </summary>
/// <param name="entireProcessTree">Whether to kill the entire process tree.</param>
public void Kill(bool entireProcessTree)
{
MarkExited(-1);
}
/// <summary>
/// Releases resources used by this process.
/// </summary>
public void Dispose()
{
}
/// <summary>
/// Marks the process as exited with the specified exit code.
/// </summary>
/// <param name="exitCode">The process exit code.</param>
public void MarkExited(int exitCode)
{
HasExited = true;
@@ -356,6 +409,9 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private readonly TaskCompletionSource<T> _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<T> _messages = [];
/// <summary>
/// Gets the recorded messages written to this stream.
/// </summary>
public IReadOnlyList<T> Messages
{
get
@@ -367,8 +423,16 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
}
}
/// <summary>
/// Gets or sets options for writing messages to the stream.
/// </summary>
public WriteOptions? WriteOptions { get; set; }
/// <summary>
/// Writes a message to the stream asynchronously.
/// </summary>
/// <param name="message">The message to write.</param>
/// <returns>Completed task.</returns>
public Task WriteAsync(T message)
{
lock (_syncRoot)
@@ -380,6 +444,11 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
return Task.CompletedTask;
}
/// <summary>
/// Waits for the first message to be written within the specified timeout.
/// </summary>
/// <param name="timeout">Maximum time to wait for the first message.</param>
/// <returns>The first message written to this stream.</returns>
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout)
{
return await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
@@ -394,43 +463,66 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private Status _status;
private WriteOptions? _writeOptions;
/// <inheritdoc />
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
/// <inheritdoc />
protected override string HostCore => "localhost";
/// <inheritdoc />
protected override string PeerCore => "ipv4:127.0.0.1:5000";
/// <inheritdoc />
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
/// <inheritdoc />
protected override Metadata RequestHeadersCore => _requestHeaders;
/// <inheritdoc />
protected override CancellationToken CancellationTokenCore => cancellationToken;
/// <inheritdoc />
protected override Metadata ResponseTrailersCore => _responseTrailers;
/// <inheritdoc />
protected override Status StatusCore
{
get => _status;
set => _status = value;
}
/// <inheritdoc />
protected override WriteOptions? WriteOptionsCore
{
get => _writeOptions;
set => _writeOptions = value;
}
/// <inheritdoc />
protected override AuthContext AuthContextCore { get; } = new(
string.Empty,
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
/// <inheritdoc />
protected override IDictionary<object, object> UserStateCore => _userState;
/// <summary>
/// Writes response headers asynchronously.
/// </summary>
/// <param name="responseHeaders">Headers to write.</param>
/// <returns>Completed task.</returns>
/// <inheritdoc />
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
{
return Task.CompletedTask;
}
/// <summary>
/// Creates a context propagation token with the specified options.
/// </summary>
/// <param name="options">Propagation options.</param>
/// <returns>Propagation token.</returns>
/// <inheritdoc />
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
{
@@ -16,6 +16,7 @@ public sealed class EventStreamServiceTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that events from the worker stream maintain their original sequence order.</summary>
[Fact]
public async Task StreamEventsAsync_YieldsEventsInWorkerOrder()
{
@@ -36,6 +37,7 @@ public sealed class EventStreamServiceTests
Assert.Equal(1, metrics.GetSnapshot().StreamDisconnects);
}
/// <summary>Verifies that a second event subscriber is rejected when one is already active.</summary>
[Fact]
public async Task StreamEventsAsync_WhenSecondSubscriberStarts_RejectsClearly()
{
@@ -64,6 +66,7 @@ public sealed class EventStreamServiceTests
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0);
}
/// <summary>Verifies that canceling an event stream detaches the subscriber cleanly.</summary>
[Fact]
public async Task StreamEventsAsync_WhenCanceled_DetachesSubscriber()
{
@@ -85,6 +88,7 @@ public sealed class EventStreamServiceTests
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0);
}
/// <summary>Verifies that disposing an event stream with buffered events resets the queue depth metric.</summary>
[Fact]
public async Task StreamEventsAsync_WhenDisposedWithBufferedEvents_ResetsStreamQueueDepth()
{
@@ -111,6 +115,7 @@ public sealed class EventStreamServiceTests
await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth == 0);
}
/// <summary>Verifies that queue depth metrics correctly track concurrent event streams across multiple sessions.</summary>
[Fact]
public async Task StreamEventsAsync_WithConcurrentStreams_TracksAggregateQueueDepth()
{
@@ -151,6 +156,7 @@ public sealed class EventStreamServiceTests
await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth == 0);
}
/// <summary>Verifies that event queue overflow faults the session and reports the overflow metric.</summary>
[Fact]
public async Task StreamEventsAsync_WhenStreamQueueOverflows_FaultsSessionAndReportsOverflow()
{
@@ -180,6 +186,7 @@ public sealed class EventStreamServiceTests
Assert.Equal(1, metrics.GetSnapshot().Faults);
}
/// <summary>Verifies that the disconnect backpressure policy disconnects the subscriber without faulting the session.</summary>
[Fact]
public async Task StreamEventsAsync_WhenStreamQueueOverflowsWithDisconnectPolicy_LeavesSessionReady()
{
@@ -211,6 +218,7 @@ public sealed class EventStreamServiceTests
Assert.Equal(1, snapshot.StreamDisconnects);
}
/// <summary>Verifies that the event stream does not synthesize OperationComplete events from write completions.</summary>
[Fact]
public async Task StreamEventsAsync_DoesNotSynthesizeOperationComplete()
{
@@ -227,6 +235,7 @@ public sealed class EventStreamServiceTests
Assert.DoesNotContain(events, candidate => candidate.Family == MxEventFamily.OperationComplete);
}
/// <summary>Verifies that a terminal fault from the worker event stream propagates and faults the session.</summary>
[Fact]
public async Task StreamEventsAsync_WhenWorkerEventStreamFaults_PropagatesTerminalFault()
{
@@ -359,15 +368,19 @@ public sealed class EventStreamServiceTests
}
}
/// <summary>Fake session manager for testing event streams.</summary>
private sealed class FakeSessionManager : ISessionManager
{
private readonly IReadOnlyDictionary<string, GatewaySession> _sessions;
/// <summary>Initializes a new instance of the FakeSessionManager.</summary>
/// <param name="sessions">Sessions to manage.</param>
public FakeSessionManager(params GatewaySession[] sessions)
{
_sessions = sessions.ToDictionary(session => session.SessionId, StringComparer.Ordinal);
}
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
@@ -376,6 +389,7 @@ public sealed class EventStreamServiceTests
return Task.FromResult(_sessions.Values.First());
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
out GatewaySession gatewaySession)
@@ -383,6 +397,7 @@ public sealed class EventStreamServiceTests
return _sessions.TryGetValue(sessionId, out gatewaySession!);
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -391,6 +406,7 @@ public sealed class EventStreamServiceTests
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -398,6 +414,7 @@ public sealed class EventStreamServiceTests
return _sessions[sessionId].ReadEventsAsync(cancellationToken);
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -405,6 +422,7 @@ public sealed class EventStreamServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
@@ -412,33 +430,44 @@ public sealed class EventStreamServiceTests
return Task.FromResult(0);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
/// <summary>Fake worker client for testing event streams.</summary>
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the list of queued worker events.</summary>
public List<WorkerEvent> Events { get; } = [];
/// <summary>Gets or sets whether to complete the event stream after configured events are yielded.</summary>
public bool CompleteAfterConfiguredEvents { get; set; }
/// <summary>Gets or sets an optional exception to throw as a terminal event stream fault.</summary>
public Exception? TerminalException { get; init; }
/// <inheritdoc />
public string SessionId { get; } = "session-events";
/// <inheritdoc />
public int? ProcessId { get; } = 4321;
/// <inheritdoc />
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -447,6 +476,7 @@ public sealed class EventStreamServiceTests
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -469,6 +499,7 @@ public sealed class EventStreamServiceTests
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
/// <inheritdoc />
public Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
@@ -477,11 +508,13 @@ public sealed class EventStreamServiceTests
return Task.CompletedTask;
}
/// <inheritdoc />
public void Kill(string reason)
{
State = WorkerClientState.Faulted;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
@@ -16,6 +16,7 @@ namespace MxGateway.Tests.Gateway.Grpc;
public sealed class MxAccessGatewayServiceTests
{
/// <summary>Verifies that OpenSession returns correct session details for a valid request.</summary>
[Fact]
public async Task OpenSession_WithValidRequest_ReturnsSessionDetails()
{
@@ -46,6 +47,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName);
}
/// <summary>Verifies that Invoke throws NotFound when the session does not exist.</summary>
[Fact]
public async Task Invoke_WhenSessionMissing_ThrowsNotFound()
{
@@ -66,6 +68,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal);
}
/// <summary>Verifies that Invoke throws InvalidArgument and does not invoke the session manager when payload is mismatched.</summary>
[Fact]
public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager()
{
@@ -88,6 +91,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal(0, sessionManager.InvokeCount);
}
/// <summary>Verifies that Invoke returns HResult status and method payload from worker reply.</summary>
[Fact]
public async Task Invoke_WithWorkerReply_ReturnsHresultStatusAndMethodPayload()
{
@@ -142,6 +146,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal("mxaccess diagnostic", reply.DiagnosticMessage);
}
/// <summary>Verifies that StreamEvents writes only events after the specified worker sequence.</summary>
[Fact]
public async Task StreamEvents_WithAfterSequence_WritesOnlyLaterEvents()
{
@@ -165,6 +170,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal("session-1", sessionManager.LastReadEventsSessionId);
}
/// <summary>Verifies that StreamEvents records send duration metrics when an event is written.</summary>
[Fact]
public async Task StreamEvents_WhenEventIsWritten_RecordsSendDuration()
{
@@ -209,6 +215,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal([MxEventFamily.OnDataChange.ToString()], families);
}
/// <summary>Verifies that CloseSession throws InvalidArgument when session ID is blank.</summary>
[Fact]
public async Task CloseSession_WithBlankSessionId_ThrowsInvalidArgument()
{
@@ -299,16 +306,22 @@ public sealed class MxAccessGatewayServiceTests
private sealed class FakeSessionManager : ISessionManager
{
/// <summary>The session to return from OpenSessionAsync.</summary>
public GatewaySession? OpenSessionResult { get; init; }
/// <summary>The last OpenSessionAsync request captured.</summary>
public SessionOpenRequest? LastOpenRequest { get; private set; }
/// <summary>The last client identity passed to OpenSessionAsync.</summary>
public string? LastClientIdentity { get; private set; }
/// <summary>The last session ID passed to ReadEventsAsync.</summary>
public string? LastReadEventsSessionId { get; private set; }
/// <summary>The last worker command passed to InvokeAsync.</summary>
public WorkerCommand? LastWorkerCommand { get; private set; }
/// <summary>The reply to return from InvokeAsync.</summary>
public WorkerCommandReply InvokeReply { get; init; } = new()
{
Reply = new MxCommandReply
@@ -319,17 +332,23 @@ public sealed class MxAccessGatewayServiceTests
},
};
/// <summary>The exception to throw from InvokeAsync.</summary>
public Exception? InvokeException { get; init; }
/// <summary>The number of times InvokeAsync was called.</summary>
public int InvokeCount { get; private set; }
/// <summary>The events to return from ReadEventsAsync.</summary>
public List<WorkerEvent> Events { get; } = [];
/// <summary>Records the session ID passed to ReadEventsAsync.</summary>
/// <param name="sessionId">Identifier of the session.</param>
public void RecordReadEventsSessionId(string sessionId)
{
LastReadEventsSessionId = sessionId;
}
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
@@ -341,6 +360,7 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234));
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
out GatewaySession session)
@@ -349,6 +369,7 @@ public sealed class MxAccessGatewayServiceTests
return true;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -365,6 +386,7 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(InvokeReply);
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -378,6 +400,7 @@ public sealed class MxAccessGatewayServiceTests
}
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -385,6 +408,7 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
@@ -392,6 +416,7 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(0);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
@@ -400,6 +425,7 @@ public sealed class MxAccessGatewayServiceTests
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
{
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -421,19 +447,25 @@ public sealed class MxAccessGatewayServiceTests
private sealed class FakeWorkerClient(int processId) : IWorkerClient
{
/// <inheritdoc />
public string SessionId { get; } = "session-1";
/// <inheritdoc />
public int? ProcessId { get; } = processId;
/// <inheritdoc />
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -442,6 +474,7 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -449,6 +482,7 @@ public sealed class MxAccessGatewayServiceTests
yield break;
}
/// <inheritdoc />
public Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
@@ -456,10 +490,12 @@ public sealed class MxAccessGatewayServiceTests
return Task.CompletedTask;
}
/// <inheritdoc />
public void Kill(string reason)
{
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
@@ -468,10 +504,13 @@ public sealed class MxAccessGatewayServiceTests
private sealed class TestServerStreamWriter<T> : IServerStreamWriter<T>
{
/// <inheritdoc />
public List<T> Messages { get; } = [];
/// <inheritdoc />
public WriteOptions? WriteOptions { get; set; }
/// <inheritdoc />
public Task WriteAsync(T message)
{
Messages.Add(message);
@@ -488,43 +527,56 @@ public sealed class MxAccessGatewayServiceTests
private Status status;
private WriteOptions? writeOptions;
/// <inheritdoc />
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
/// <inheritdoc />
protected override string HostCore => "localhost";
/// <inheritdoc />
protected override string PeerCore => "ipv4:127.0.0.1:5000";
/// <inheritdoc />
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
/// <inheritdoc />
protected override Metadata RequestHeadersCore => requestHeaders;
/// <inheritdoc />
protected override CancellationToken CancellationTokenCore => cancellationToken;
/// <inheritdoc />
protected override Metadata ResponseTrailersCore => responseTrailers;
/// <inheritdoc />
protected override Status StatusCore
{
get => status;
set => status = value;
}
/// <inheritdoc />
protected override WriteOptions? WriteOptionsCore
{
get => writeOptions;
set => writeOptions = value;
}
/// <inheritdoc />
protected override AuthContext AuthContextCore { get; } = new(
string.Empty,
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
/// <inheritdoc />
protected override IDictionary<object, object> UserStateCore => userState;
/// <inheritdoc />
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
{
return Task.CompletedTask;
}
/// <inheritdoc />
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
{
@@ -5,6 +5,7 @@ namespace MxGateway.Tests.Gateway.Grpc;
public sealed class MxAccessGrpcMapperTests
{
/// <summary>Verifies that command mapping clones payloads to isolate them across process boundaries.</summary>
[Fact]
public void MapCommand_ClonesMethodSpecificPayloadForWorkerBoundary()
{
@@ -37,6 +38,7 @@ public sealed class MxAccessGrpcMapperTests
Assert.NotNull(workerCommand.EnqueueTimestamp);
}
/// <summary>Verifies that command reply mapping preserves HRESULT and status information.</summary>
[Fact]
public void MapCommandReply_PreservesHresultStatusesAndPayload()
{
@@ -66,6 +68,7 @@ public sealed class MxAccessGrpcMapperTests
Assert.Equal("denied", Assert.Single(publicReply.Statuses).DiagnosticText);
}
/// <summary>Verifies that a missing worker reply returns a protocol violation status.</summary>
[Fact]
public void MapCommandReply_WhenWorkerReplyMissing_ReturnsProtocolViolationReply()
{
@@ -10,6 +10,7 @@ namespace MxGateway.Tests.Gateway.Sessions;
public sealed class SessionManagerTests
{
/// <summary>Verifies that opening a session with a ready worker registers the session in ready state.</summary>
[Fact]
public async Task OpenSessionAsync_WithWorkerReady_RegistersReadySession()
{
@@ -32,6 +33,7 @@ public sealed class SessionManagerTests
Assert.Equal(1, metrics.GetSnapshot().SessionsOpened);
}
/// <summary>Verifies that opening a session generates a correlation ID from the client name and session ID.</summary>
[Fact]
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
{
@@ -47,6 +49,7 @@ public sealed class SessionManagerTests
Assert.Equal($"rust-load-client-{session.SessionId}", session.ClientCorrelationId);
}
/// <summary>Verifies that opening a session without a client session name uses the client correlation prefix.</summary>
[Fact]
public async Task OpenSessionAsync_WhenClientSessionNameMissing_UsesClientCorrelationPrefix()
{
@@ -61,6 +64,7 @@ public sealed class SessionManagerTests
Assert.Equal($"client-{session.SessionId}", session.ClientCorrelationId);
}
/// <summary>Verifies that invoking a command on a ready session forwards the command to the worker.</summary>
[Fact]
public async Task InvokeAsync_WhenSessionReady_ForwardsCommandToWorker()
{
@@ -77,6 +81,7 @@ public sealed class SessionManagerTests
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
}
/// <summary>Verifies that bulk subscribe forwards the command and returns subscription results.</summary>
[Fact]
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -121,6 +126,7 @@ public sealed class SessionManagerTests
Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.SubscribeBulk.TagAddresses);
}
/// <summary>Verifies that invoking a command on a faulted session rejects the command.</summary>
[Fact]
public async Task InvokeAsync_WhenSessionFaulted_RejectsCommand()
{
@@ -139,6 +145,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, workerClient.InvokeCount);
}
/// <summary>Verifies that closing a session removes it from the registry.</summary>
[Fact]
public async Task CloseSessionAsync_RemovesClosedSession()
{
@@ -159,6 +166,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>Verifies that closing a session kills the worker when shutdown fails.</summary>
[Fact]
public async Task CloseSessionAsync_WhenWorkerShutdownFails_KillsWorker()
{
@@ -179,6 +187,7 @@ public sealed class SessionManagerTests
Assert.Equal(1, workerClient.KillCount);
}
/// <summary>Verifies that when worker shutdown fails, the session is removed and the slot is released.</summary>
[Fact]
public async Task CloseSessionAsync_WhenWorkerShutdownFails_RemovesSessionAndReleasesSlot()
{
@@ -221,6 +230,7 @@ public sealed class SessionManagerTests
Assert.Equal(1, snapshot.OpenSessions);
}
/// <summary>Verifies that when the second close is canceled, the session is not removed if owned by the first close.</summary>
[Fact]
public async Task CloseSessionAsync_WhenSecondCloseIsCanceled_DoesNotRemoveSessionOwnedByFirstClose()
{
@@ -268,6 +278,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>Verifies that when worker creation fails, the session is removed from the registry.</summary>
[Fact]
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
{
@@ -287,6 +298,7 @@ public sealed class SessionManagerTests
Assert.Equal(1, metrics.GetSnapshot().Faults);
}
/// <summary>Verifies that closing expired leases only closes expired sessions.</summary>
[Fact]
public async Task CloseExpiredLeasesAsync_ClosesExpiredSessionsOnly()
{
@@ -309,6 +321,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, activeClient.ShutdownCount);
}
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
[Fact]
public async Task ShutdownAsync_ClosesAllRegisteredSessions()
{
@@ -330,6 +343,12 @@ public sealed class SessionManagerTests
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>Creates a session manager for testing.</summary>
/// <param name="factory">Worker client factory.</param>
/// <param name="registry">Session registry; defaults to a new registry.</param>
/// <param name="metrics">Metrics collector; defaults to a new instance.</param>
/// <param name="options">Gateway options; defaults to test defaults.</param>
/// <returns>Configured session manager.</returns>
private static SessionManager CreateManager(
ISessionWorkerClientFactory factory,
ISessionRegistry? registry = null,
@@ -382,10 +401,13 @@ public sealed class SessionManagerTests
private sealed class FakeSessionWorkerClientFactory(IWorkerClient workerClient) : ISessionWorkerClientFactory
{
/// <summary>Gets the list of observed session states during worker creation.</summary>
public List<string> ObservedStates { get; } = [];
/// <summary>Gets or sets a value indicating whether to apply lifecycle transitions during worker creation.</summary>
public bool ApplyLifecycleTransitions { get; init; }
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
@@ -409,11 +431,14 @@ public sealed class SessionManagerTests
{
private readonly Queue<IWorkerClient> _workerClients;
/// <summary>Initializes a new instance of the <see cref="QueueingSessionWorkerClientFactory"/> class.</summary>
/// <param name="workerClients">Array of worker clients to queue.</param>
public QueueingSessionWorkerClientFactory(params IWorkerClient[] workerClients)
{
_workerClients = new Queue<IWorkerClient>(workerClients);
}
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
@@ -424,6 +449,7 @@ public sealed class SessionManagerTests
private sealed class FailingSessionWorkerClientFactory : ISessionWorkerClientFactory
{
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
@@ -434,39 +460,53 @@ public sealed class SessionManagerTests
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the session ID for the fake worker client.</summary>
public string SessionId { get; init; } = "session-1";
/// <summary>Gets the process ID for the fake worker client.</summary>
public int? ProcessId { get; init; } = 1234;
/// <summary>Gets or sets the state of the fake worker client.</summary>
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
/// <summary>Gets the last heartbeat timestamp for the fake worker client.</summary>
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>Gets the number of times invoke was called on the fake worker client.</summary>
public int InvokeCount { get; private set; }
/// <summary>Gets the number of times shutdown was called on the fake worker client.</summary>
public int ShutdownCount { get; private set; }
/// <summary>Gets the number of times kill was called on the fake worker client.</summary>
public int KillCount { get; private set; }
/// <summary>Gets the number of times dispose was called on the fake worker client.</summary>
public int DisposeCount { get; private set; }
/// <summary>Gets the exception to throw when shutdown is called, if any.</summary>
public Exception? ShutdownException { get; init; }
/// <summary>Gets a value indicating whether to block shutdown on the fake worker client.</summary>
public bool BlockShutdown { get; init; }
/// <summary>Gets the last command invoked on the fake worker client.</summary>
public WorkerCommand? LastCommand { get; private set; }
/// <summary>Gets the reply to return for invoke calls on the fake worker client.</summary>
public WorkerCommandReply? InvokeReply { get; init; }
private TaskCompletionSource ShutdownStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource ShutdownReleased { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -492,6 +532,7 @@ public sealed class SessionManagerTests
});
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -499,6 +540,7 @@ public sealed class SessionManagerTests
yield break;
}
/// <inheritdoc />
public async Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
@@ -518,23 +560,27 @@ public sealed class SessionManagerTests
State = WorkerClientState.Closed;
}
/// <inheritdoc />
public void Kill(string reason)
{
KillCount++;
State = WorkerClientState.Faulted;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
/// <summary>Waits for shutdown to start on the fake worker client.</summary>
public Task WaitForShutdownStartAsync()
{
return ShutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>Releases the shutdown block on the fake worker client.</summary>
public void ReleaseShutdown()
{
ShutdownReleased.TrySetResult();
@@ -14,6 +14,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that the factory creates a ready worker client with a scripted fake worker process.</summary>
[Fact]
public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient()
{
@@ -46,6 +47,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
}
/// <summary>Verifies that a failed fake worker startup throws a worker client exception.</summary>
[Fact]
public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException()
{
@@ -65,6 +67,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
Assert.True(launcher.Process.IsDisposed);
}
/// <summary>Verifies that a worker that never sends ready times out and is killed.</summary>
[Fact]
public async Task CreateAsync_WhenFakeWorkerNeverSendsReady_TimesOutAndKillsWorker()
{
@@ -131,13 +134,17 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
};
}
/// <summary>Fake worker launcher that connects a scripted fake worker harness.</summary>
private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerProcessLauncher
{
/// <summary>The fake process ID used by the scripted launcher.</summary>
public const int ProcessId = 2468;
private readonly FakeWorkerProcess _process = new(ProcessId);
/// <summary>Gets the connected fake worker harness.</summary>
public FakeWorkerHarness? Harness { get; private set; }
/// <inheritdoc />
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
@@ -161,10 +168,13 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
}
}
/// <summary>Fake worker launcher that fails during startup with protocol version mismatch.</summary>
private sealed class FailingStartupWorkerProcessLauncher : IWorkerProcessLauncher
{
/// <summary>Gets the fake worker process.</summary>
public FakeWorkerProcess Process { get; } = new(processId: 3579);
/// <inheritdoc />
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
@@ -192,10 +202,13 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
}
}
/// <summary>Fake worker launcher that never completes startup, simulating a hung worker.</summary>
private sealed class NeverReadyWorkerProcessLauncher : IWorkerProcessLauncher
{
/// <summary>Gets the fake worker process.</summary>
public FakeWorkerProcess Process { get; } = new(processId: 4680);
/// <inheritdoc />
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
@@ -232,18 +245,24 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
DateTimeOffset.UtcNow);
}
/// <summary>Fake worker process for testing process lifecycle.</summary>
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
private bool _disposed;
/// <inheritdoc />
public int Id { get; } = processId;
/// <summary>Gets or sets a value indicating whether the process has exited.</summary>
public bool HasExited { get; private set; }
/// <summary>Gets or sets the process exit code.</summary>
public int? ExitCode { get; private set; }
/// <summary>Gets the number of times the Kill method was called.</summary>
public int KillCount { get; private set; }
/// <inheritdoc />
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
HasExited = true;
@@ -251,6 +270,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public void Kill(bool entireProcessTree)
{
KillCount++;
@@ -258,11 +278,13 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
ExitCode = -1;
}
/// <inheritdoc />
public void Dispose()
{
_disposed = true;
}
/// <summary>Gets a value indicating whether this process has been disposed.</summary>
public bool IsDisposed => _disposed;
}
}
@@ -9,6 +9,7 @@ public sealed class FakeWorkerHarnessTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that completing startup with hello and ready transitions the client to ready state.</summary>
[Fact]
public async Task CompleteStartupAsync_WithHelloAndReady_TransitionsClientToReady()
{
@@ -25,6 +26,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, client.ProcessId);
}
/// <summary>Verifies that a protocol version mismatch during startup fails the client.</summary>
[Fact]
public async Task StartAsync_WithProtocolMismatch_FailsStartup()
{
@@ -43,6 +45,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientErrorCode.ProtocolViolation, exception.ErrorCode);
}
/// <summary>Verifies that a scripted reply completes a pending command invocation.</summary>
[Fact]
public async Task InvokeAsync_WithScriptedReply_CompletesCommand()
{
@@ -64,6 +67,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
}
/// <summary>Verifies that scripted events are yielded in order through the event stream.</summary>
[Fact]
public async Task ReadEventsAsync_WithScriptedEvents_YieldsOrderedEvents()
{
@@ -87,6 +91,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family);
}
/// <summary>Verifies that a scripted fault from the worker faults the client.</summary>
[Fact]
public async Task ReadLoop_WithScriptedFault_FaultsClient()
{
@@ -105,6 +110,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that sending a heartbeat updates the client heartbeat state.</summary>
[Fact]
public async Task SendHeartbeatAsync_UpdatesClientHeartbeatState()
{
@@ -124,6 +130,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientState.Ready, client.State);
}
/// <summary>Verifies that a hung worker times out pending command invocations.</summary>
[Fact]
public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand()
{
@@ -144,6 +151,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientErrorCode.CommandTimeout, exception.ErrorCode);
}
/// <summary>Verifies that a malformed frame in the read loop faults the client.</summary>
[Fact]
public async Task ReadLoop_WithMalformedFrame_FaultsClient()
{
@@ -160,6 +168,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that a shutdown acknowledgment from the worker closes the client.</summary>
[Fact]
public async Task ShutdownAsync_WithShutdownAck_ClosesClient()
{
@@ -37,12 +37,21 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
_writer = new WorkerFrameWriter(_workerStream, frameOptions);
}
/// <summary>Gets the session ID for the fake worker harness.</summary>
public string SessionId { get; }
/// <summary>Gets the nonce for the fake worker harness.</summary>
public string Nonce { get; }
/// <summary>Gets or sets the next worker sequence number.</summary>
public ulong NextWorkerSequence { get; private set; }
/// <summary>Creates a connected pair of fake worker harness with gateway and worker pipes.</summary>
/// <param name="sessionId">Identifier for the fake session.</param>
/// <param name="nonce">Nonce for session validation.</param>
/// <param name="protocolVersion">Protocol version for frame communication.</param>
/// <param name="maxMessageBytes">Maximum message size in bytes.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public static async Task<FakeWorkerHarness> CreateConnectedPairAsync(
string sessionId = DefaultSessionId,
string nonce = DefaultNonce,
@@ -71,6 +80,13 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes));
}
/// <summary>Connects to an existing gateway pipe as a fake worker harness.</summary>
/// <param name="sessionId">Identifier for the fake session.</param>
/// <param name="nonce">Nonce for session validation.</param>
/// <param name="pipeName">Name of the named pipe to connect to.</param>
/// <param name="protocolVersion">Protocol version for frame communication.</param>
/// <param name="maxMessageBytes">Maximum message size in bytes.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public static async Task<FakeWorkerHarness> ConnectToGatewayPipeAsync(
string sessionId,
string nonce,
@@ -90,6 +106,11 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes));
}
/// <summary>Creates a worker client connected to the fake worker harness.</summary>
/// <param name="options">Configuration options for the worker client.</param>
/// <param name="metrics">Gateway metrics collector.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <returns>A configured worker client connected to this harness.</returns>
public WorkerClient CreateClient(
WorkerClientOptions? options = null,
GatewayMetrics? metrics = null,
@@ -109,6 +130,13 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return new WorkerClient(connection, options, metrics, timeProvider);
}
/// <summary>Completes the worker startup handshake by reading the gateway hello and sending worker hello and ready.</summary>
/// <param name="workerProcessId">Process ID of the fake worker.</param>
/// <param name="workerVersion">Version string of the fake worker.</param>
/// <param name="mxaccessProgid">MXAccess COM ProgID.</param>
/// <param name="mxaccessClsid">MXAccess COM CLSID.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The gateway hello envelope received during startup.</returns>
public async Task<WorkerEnvelope> CompleteStartupAsync(
int workerProcessId = DefaultWorkerProcessId,
string workerVersion = "fake-worker",
@@ -135,11 +163,17 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return gatewayHello;
}
/// <summary>Reads the next gateway envelope from the worker stream.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The gateway envelope read from the stream.</returns>
public async Task<WorkerEnvelope> ReadGatewayEnvelopeAsync(CancellationToken cancellationToken = default)
{
return await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Reads the next command from the worker stream.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The command envelope read from the stream.</returns>
public async Task<WorkerEnvelope> ReadCommandAsync(CancellationToken cancellationToken = default)
{
WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
@@ -151,6 +185,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return envelope;
}
/// <summary>Reads the next shutdown request from the worker stream.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The shutdown envelope read from the stream.</returns>
public async Task<WorkerEnvelope> ReadShutdownAsync(CancellationToken cancellationToken = default)
{
WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
@@ -162,6 +199,12 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return envelope;
}
/// <summary>Sends a worker hello message to the gateway.</summary>
/// <param name="workerProcessId">Process ID of the fake worker.</param>
/// <param name="workerVersion">Version string of the fake worker.</param>
/// <param name="workerProtocolVersion">Protocol version override.</param>
/// <param name="nonce">Nonce override.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task SendWorkerHelloAsync(
int workerProcessId = DefaultWorkerProcessId,
string workerVersion = "fake-worker",
@@ -182,6 +225,11 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a worker ready message to the gateway.</summary>
/// <param name="workerProcessId">Process ID of the fake worker.</param>
/// <param name="mxaccessProgid">MXAccess COM ProgID.</param>
/// <param name="mxaccessClsid">MXAccess COM CLSID.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task SendWorkerReadyAsync(
int workerProcessId = DefaultWorkerProcessId,
string mxaccessProgid = "LMXProxy.LMXProxyServer.1",
@@ -201,6 +249,12 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a reply to a command received from the gateway.</summary>
/// <param name="commandEnvelope">The command envelope to reply to.</param>
/// <param name="statusCode">Protocol status code for the reply.</param>
/// <param name="statusMessage">Human-readable status message.</param>
/// <param name="configureReply">Optional callback to customize the reply.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task ReplyToCommandAsync(
WorkerEnvelope commandEnvelope,
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
@@ -238,6 +292,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Emits an event to the gateway.</summary>
/// <param name="family">Family of the event to emit.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <param name="configureEvent">Optional callback to customize the event.</param>
public async Task EmitEventAsync(
MxEventFamily family,
CancellationToken cancellationToken = default,
@@ -263,6 +321,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Emits a fault message to the gateway.</summary>
/// <param name="category">Category of the fault.</param>
/// <param name="diagnosticMessage">Diagnostic message describing the fault.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task EmitFaultAsync(
WorkerFaultCategory category,
string diagnosticMessage,
@@ -284,6 +346,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a heartbeat message to the gateway.</summary>
/// <param name="state">Current worker state.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <param name="configureHeartbeat">Optional callback to customize the heartbeat.</param>
public async Task SendHeartbeatAsync(
WorkerState state = WorkerState.Ready,
CancellationToken cancellationToken = default,
@@ -304,6 +370,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a shutdown acknowledgment message to the gateway.</summary>
/// <param name="statusCode">Protocol status code for the acknowledgment.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task SendShutdownAckAsync(
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
CancellationToken cancellationToken = default)
@@ -322,6 +391,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Writes a malformed payload directly to the worker stream.</summary>
/// <param name="payload">Malformed payload bytes to write.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task WriteMalformedPayloadAsync(
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken = default)
@@ -337,6 +409,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
await _workerStream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
}
/// <summary>Writes an oversized frame header to the worker stream for testing frame size limits.</summary>
/// <param name="payloadLength">Length of the oversized payload in bytes.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task WriteOversizedFrameHeaderAsync(
uint payloadLength,
CancellationToken cancellationToken = default)
@@ -354,6 +429,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
await _workerStream.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false);
}
/// <summary>Disposes the worker-side stream.</summary>
public async ValueTask DisposeWorkerSideAsync()
{
if (_workerSideDisposed)
@@ -365,6 +441,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
_workerSideDisposed = true;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await DisposeWorkerSideAsync().ConfigureAwait(false);
@@ -14,6 +14,7 @@ public sealed class WorkerClientTests
private const int WorkerProcessId = 4321;
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that StartAsync enters ready state after receiving worker hello and ready messages.</summary>
[Fact]
public async Task StartAsync_WithWorkerHelloAndReady_EntersReadyState()
{
@@ -26,6 +27,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerProcessId, client.ProcessId);
}
/// <summary>Verifies that InvokeAsync completes a pending command when a matching reply arrives.</summary>
[Fact]
public async Task InvokeAsync_WithMatchingReply_CompletesPendingCommand()
{
@@ -51,6 +53,7 @@ public sealed class WorkerClientTests
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
}
/// <summary>Verifies that InvokeAsync ignores late replies and keeps the client ready.</summary>
[Fact]
public async Task InvokeAsync_WithLateReply_IgnoresLateReplyAndKeepsClientReady()
{
@@ -86,6 +89,7 @@ public sealed class WorkerClientTests
Assert.Equal(MxCommandKind.GetWorkerInfo, reply.Reply.Kind);
}
/// <summary>Verifies that ReadEventsAsync yields events in pipe order from the worker.</summary>
[Fact]
public async Task ReadEventsAsync_WithWorkerEvents_YieldsEventsInPipeOrder()
{
@@ -111,6 +115,7 @@ public sealed class WorkerClientTests
Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family);
}
/// <summary>Verifies that the read loop faults the client when the event queue overflows.</summary>
[Fact]
public async Task ReadLoop_WhenEventQueueOverflows_FaultsClient()
{
@@ -137,6 +142,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that the read loop faults the client when the pipe disconnects.</summary>
[Fact]
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
{
@@ -153,6 +159,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that the read loop stops the running worker metric when the pipe disconnects.</summary>
[Fact]
public async Task ReadLoop_WhenPipeDisconnects_StopsRunningWorkerMetric()
{
@@ -175,6 +182,7 @@ public sealed class WorkerClientTests
Assert.Equal(1, snapshot.WorkerExits);
}
/// <summary>Verifies that DisposeAsync returns within a bounded timeout when the pipe read is blocked.</summary>
[Fact]
public async Task DisposeAsync_WhenPipeReadIsBlocked_ReturnsWithinBoundedTimeout()
{
@@ -191,6 +199,7 @@ public sealed class WorkerClientTests
$"DisposeAsync took {elapsed.TotalMilliseconds:N0}ms.");
}
/// <summary>Verifies that the read loop updates the last heartbeat and worker process when a heartbeat arrives.</summary>
[Fact]
public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess()
{
@@ -209,6 +218,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Ready, client.State);
}
/// <summary>Verifies that the heartbeat monitor faults the client when the heartbeat expires.</summary>
[Fact]
public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient()
{
@@ -393,12 +403,16 @@ public sealed class WorkerClientTests
WorkerWriter = new WorkerFrameWriter(_workerStream, new WorkerFrameProtocolOptions(SessionId));
}
/// <summary>The gateway side of the named pipe connection.</summary>
public NamedPipeServerStream GatewayStream { get; }
/// <summary>Frame reader for worker messages.</summary>
public WorkerFrameReader WorkerReader { get; }
/// <summary>Frame writer for worker messages.</summary>
public WorkerFrameWriter WorkerWriter { get; }
/// <summary>Creates a connected pipe pair for testing.</summary>
public static async Task<PipePair> CreateAsync()
{
string pipeName = $"mxaccessgw-workerclient-tests-{Guid.NewGuid():N}";
@@ -421,6 +435,7 @@ public sealed class WorkerClientTests
return new PipePair(gatewayStream, workerStream);
}
/// <summary>Disposes the worker side of the pipe.</summary>
public async ValueTask DisposeWorkerSideAsync()
{
if (_workerSideDisposed)
@@ -432,6 +447,7 @@ public sealed class WorkerClientTests
_workerSideDisposed = true;
}
/// <summary>Disposes the duplex stream.</summary>
public async ValueTask DisposeAsync()
{
await DisposeWorkerSideAsync();
@@ -10,6 +10,7 @@ public sealed class WorkerFrameProtocolTests
{
private const string SessionId = "session-1";
/// <summary>Verifies that writing and reading a valid envelope round-trips the frame correctly.</summary>
[Fact]
public async Task WriteAndReadAsync_WithValidEnvelope_RoundTripsFrame()
{
@@ -27,6 +28,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(original, parsed);
}
/// <summary>Verifies that reading a frame with partial reads reassembles the frame correctly.</summary>
[Fact]
public async Task ReadAsync_WithPartialReads_ReassemblesFrame()
{
@@ -42,6 +44,7 @@ public sealed class WorkerFrameProtocolTests
Assert.True(stream.ReadCallCount > 2);
}
/// <summary>Verifies that reading a frame with zero length throws a malformed length exception.</summary>
[Fact]
public async Task ReadAsync_WithZeroLengthFrame_ThrowsMalformedLength()
{
@@ -56,6 +59,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.MalformedLength, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with oversized length throws before allocating the payload.</summary>
[Fact]
public async Task ReadAsync_WithOversizedLength_ThrowsBeforePayloadAllocation()
{
@@ -72,6 +76,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with wrong protocol version throws a protocol version mismatch exception.</summary>
[Fact]
public async Task ReadAsync_WithWrongProtocolVersion_ThrowsProtocolVersionMismatch()
{
@@ -88,6 +93,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.ProtocolVersionMismatch, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with wrong session ID throws a session mismatch exception.</summary>
[Fact]
public async Task ReadAsync_WithWrongSessionId_ThrowsSessionMismatch()
{
@@ -104,6 +110,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.SessionMismatch, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with malformed payload throws an invalid envelope exception.</summary>
[Fact]
public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope()
{
@@ -119,6 +126,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with missing envelope body throws an invalid envelope exception.</summary>
[Fact]
public async Task ReadAsync_WithMissingEnvelopeBody_ThrowsInvalidEnvelope()
{
@@ -135,6 +143,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode);
}
/// <summary>Verifies that writing an oversized envelope throws a message too large exception.</summary>
[Fact]
public async Task WriteAsync_WithOversizedEnvelope_ThrowsMessageTooLarge()
{
@@ -186,6 +195,9 @@ public sealed class WorkerFrameProtocolTests
{
private readonly int _chunkSize;
/// <summary>Initializes a new instance of the <see cref="ChunkedReadStream"/> class with chunked reads.</summary>
/// <param name="buffer">The buffer containing data to read.</param>
/// <param name="chunkSize">The maximum number of bytes to read per operation.</param>
public ChunkedReadStream(
byte[] buffer,
int chunkSize)
@@ -194,8 +206,10 @@ public sealed class WorkerFrameProtocolTests
_chunkSize = chunkSize;
}
/// <summary>Gets the number of read calls made to the stream.</summary>
public int ReadCallCount { get; private set; }
/// <inheritdoc />
public override ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default)
@@ -13,6 +13,7 @@ public sealed class WorkerProcessLauncherTests
private const string PipeName = "mxaccess-gateway-123-session-1";
private const string Nonce = "super-secret-nonce";
/// <summary>Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable.</summary>
[Fact]
public async Task LaunchAsync_WithValidWorker_StartsProcessWithBootstrapArgumentsAndNonceEnvironment()
{
@@ -46,6 +47,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(0, metrics.GetSnapshot().WorkersRunning);
}
/// <summary>Verifies that a failed startup probe kills and disposes the worker process.</summary>
[Fact]
public async Task LaunchAsync_WhenStartupProbeFails_KillsAndDisposesWorker()
{
@@ -71,6 +73,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, metrics.GetSnapshot().WorkerKills);
}
/// <summary>Verifies that transient startup probe failures are retried without respawning the worker process.</summary>
[Fact]
public async Task LaunchAsync_WhenStartupProbeFailsTransiently_RetriesWithoutRespawningWorker()
{
@@ -97,6 +100,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, snapshot.RetryAttemptsByArea["worker_startup"]);
}
/// <summary>Verifies that a startup probe timeout kills and disposes the worker process.</summary>
[Fact]
public async Task LaunchAsync_WhenStartupTimesOut_KillsAndDisposesWorker()
{
@@ -121,6 +125,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, metrics.GetSnapshot().WorkerKills);
}
/// <summary>Verifies that a missing worker executable fails before attempting to start the process.</summary>
[Fact]
public async Task LaunchAsync_WhenExecutableDoesNotExist_FailsBeforeStartingProcess()
{
@@ -137,6 +142,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Null(processFactory.LastStartInfo);
}
/// <summary>Verifies that a worker executable with mismatched architecture fails before attempting to start.</summary>
[Fact]
public async Task LaunchAsync_WhenExecutableArchitectureDoesNotMatch_FailsBeforeStartingProcess()
{
@@ -153,6 +159,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Null(processFactory.LastStartInfo);
}
/// <summary>Verifies that a worker that has already exited fails and disposes without additional killing.</summary>
[Fact]
public async Task LaunchAsync_WhenWorkerAlreadyExited_FailsAndDisposesWorkerWithoutKill()
{
@@ -215,12 +222,16 @@ public sealed class WorkerProcessLauncherTests
pipeReservation);
}
/// <summary>Fake worker process factory for testing process launch logic.</summary>
private sealed class FakeWorkerProcessFactory(IWorkerProcess process) : IWorkerProcessFactory
{
/// <summary>Gets the most recent process start information.</summary>
public ProcessStartInfo? LastStartInfo { get; private set; }
/// <summary>Gets the number of times the process factory has started a process.</summary>
public int StartCount { get; private set; }
/// <inheritdoc />
public IWorkerProcess Start(ProcessStartInfo startInfo)
{
StartCount++;
@@ -229,23 +240,31 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake worker process for testing process lifecycle and exit behavior.</summary>
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
/// <inheritdoc />
public int Id { get; } = processId;
/// <summary>Gets or sets a value indicating whether the process has exited.</summary>
public bool HasExited { get; set; }
/// <summary>Gets or sets the process exit code.</summary>
public int? ExitCode { get; set; }
/// <summary>Gets a value indicating whether the Dispose method was called.</summary>
public bool DisposeCalled { get; private set; }
/// <summary>Gets a value indicating whether the Kill method was called.</summary>
public bool KillCalled { get; private set; }
/// <inheritdoc />
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public void Kill(bool entireProcessTree)
{
Assert.True(entireProcessTree);
@@ -253,14 +272,17 @@ public sealed class WorkerProcessLauncherTests
HasExited = true;
}
/// <inheritdoc />
public void Dispose()
{
DisposeCalled = true;
}
}
/// <summary>Fake startup probe that immediately succeeds.</summary>
private sealed class SucceedingStartupProbe : IWorkerStartupProbe
{
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -270,8 +292,10 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake startup probe that always fails.</summary>
private sealed class FailingStartupProbe : IWorkerStartupProbe
{
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -281,8 +305,10 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake startup probe that waits indefinitely to simulate a startup timeout.</summary>
private sealed class WaitingStartupProbe : IWorkerStartupProbe
{
/// <inheritdoc />
public async Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -292,10 +318,12 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake startup probe that fails a configurable number of times before succeeding.</summary>
private sealed class TransientStartupProbe(int failuresBeforeSuccess) : IWorkerStartupProbe
{
private int _attempts;
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -310,16 +338,20 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake pipe reservation for testing pipe lifecycle.</summary>
private sealed class FakePipeReservation : IDisposable
{
/// <summary>Gets a value indicating whether the Dispose method was called.</summary>
public bool DisposeCalled { get; private set; }
/// <inheritdoc />
public void Dispose()
{
DisposeCalled = true;
}
}
/// <summary>Test helper that creates and cleans up a temporary directory for worker executable tests.</summary>
private sealed class TestDirectory : IDisposable
{
private TestDirectory(string path)
@@ -327,8 +359,10 @@ public sealed class WorkerProcessLauncherTests
Path = path;
}
/// <summary>Gets the path to the temporary test directory.</summary>
public string Path { get; }
/// <summary>Creates a new temporary directory for testing.</summary>
public static TestDirectory Create()
{
string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"mxgateway-tests-{Guid.NewGuid():N}");
@@ -337,6 +371,9 @@ public sealed class WorkerProcessLauncherTests
return new TestDirectory(path);
}
/// <summary>Creates a fake PE executable with the specified machine architecture for testing.</summary>
/// <param name="machine">PE machine type constant (0x014c for x86, 0x8664 for x64).</param>
/// <returns>Full path to the created executable file.</returns>
public string CreateWorkerExecutable(ushort machine)
{
string path = System.IO.Path.Combine(Path, "MxGateway.Worker.exe");
@@ -354,6 +391,7 @@ public sealed class WorkerProcessLauncherTests
return path;
}
/// <inheritdoc />
public void Dispose()
{
Directory.Delete(Path, recursive: true);