test(gateway): end-to-end multi-subscriber fan-out via fake worker

Adds GatewayEndToEndMultiSubscriberTests covering three scenarios
through the real gRPC StreamEvents path with AllowMultipleEventSubscribers=true:
- Fan-out: two concurrent StreamEvents RPCs both receive every event the fake
  worker emits, in the same order (WorkerSequence matches, values indexed).
- Independent cancellation: cancelling one subscriber's stream leaves the other
  receiving subsequent events; the session stays usable.
- Cap enforcement: with MaxEventSubscribersPerSession=2 a third concurrent
  StreamEvents is rejected with gRPC ResourceExhausted while the first two
  keep streaming.

Extends RecordingServerStreamWriter<T> with WaitForMessageCountAsync to
allow deterministic bounded-timeout awaits for an N-message count without
fixed sleeps.
This commit is contained in:
Joseph Doherty
2026-06-15 16:09:58 -04:00
parent 281e00b300
commit 9dd97a27f1
2 changed files with 791 additions and 2 deletions
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
/// <summary>
/// Thread-safe <see cref="IServerStreamWriter{T}"/> that records every written message
/// and lets a test await the first message with a timeout.
/// and lets a test await the first message or a specific message count with a timeout.
/// </summary>
/// <typeparam name="T">The streamed message type.</typeparam>
public sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
@@ -12,6 +12,7 @@ public sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
private readonly object _syncRoot = new();
private readonly TaskCompletionSource<T> _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<T> _messages = [];
private readonly List<TaskCompletionSource<IReadOnlyList<T>>> _countWaiters = [];
/// <summary>Gets the messages written to this stream, in order.</summary>
public IReadOnlyList<T> Messages
@@ -33,12 +34,31 @@ public sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
/// <returns>A completed task.</returns>
public Task WriteAsync(T message)
{
List<TaskCompletionSource<IReadOnlyList<T>>>? satisfied = null;
IReadOnlyList<T>? snapshot = null;
lock (_syncRoot)
{
_messages.Add(message);
_firstMessage.TrySetResult(message);
// Check whether any count waiters are now satisfied.
if (_countWaiters.Count > 0)
{
snapshot = _messages.ToArray();
satisfied = _countWaiters.ToList();
_countWaiters.Clear();
}
}
if (satisfied is not null && snapshot is not null)
{
foreach (TaskCompletionSource<IReadOnlyList<T>> waiter in satisfied)
{
waiter.TrySetResult(snapshot);
}
}
_firstMessage.TrySetResult(message);
return Task.CompletedTask;
}
@@ -47,4 +67,53 @@ public sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
/// <returns>The first message written to this stream.</returns>
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout) =>
await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
/// <summary>
/// Waits until at least <paramref name="count"/> messages have been written, then returns
/// the current snapshot. The wait is bounded by <paramref name="timeout"/>; if fewer than
/// <paramref name="count"/> messages arrive within the timeout the call throws
/// <see cref="TimeoutException"/> (surfaced as <see cref="OperationCanceledException"/>
/// from <see cref="Task.WaitAsync(TimeSpan)"/>).
/// </summary>
/// <param name="count">Minimum number of messages to wait for.</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <returns>A snapshot of all messages received so far (at least <paramref name="count"/>).</returns>
public async Task<IReadOnlyList<T>> WaitForMessageCountAsync(int count, TimeSpan timeout)
{
TaskCompletionSource<IReadOnlyList<T>>? tcs = null;
lock (_syncRoot)
{
if (_messages.Count >= count)
{
return _messages.ToArray();
}
tcs = new TaskCompletionSource<IReadOnlyList<T>>(TaskCreationOptions.RunContinuationsAsynchronously);
_countWaiters.Add(tcs);
}
// Poll: re-check each time any message arrives. The TCS is satisfied on EVERY write,
// but the caller may need more messages, so we loop until the count is met.
while (true)
{
IReadOnlyList<T> snapshot = await tcs.Task.WaitAsync(timeout).ConfigureAwait(false);
if (snapshot.Count >= count)
{
return snapshot;
}
// Not enough yet — register a new waiter and keep waiting.
lock (_syncRoot)
{
if (_messages.Count >= count)
{
return _messages.ToArray();
}
tcs = new TaskCompletionSource<IReadOnlyList<T>>(TaskCreationOptions.RunContinuationsAsynchronously);
_countWaiters.Add(tcs);
}
}
}
}