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
@@ -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()
{