Skip to content

Commit 6ef3c80

Browse files
ANcpLuaclaude
andcommitted
Achieve 100% line coverage with FakeCompletableSseStream
Added test-only ISseStream implementation that enables natural stream completion without cancellation. This allows await foreach loops to exit through their normal code paths rather than via exception handling, covering previously unreachable async method closing braces. Changes: - Add FakeCompletableSseStream<T> using Channel<T> with Complete() method - Add MapSse_Fallback_CompletesNaturallyWhenStreamEnds test (net9.0) - Add MapSse_Net10_CompletesNaturallyWhenStreamEnds test (net10.0) Coverage Results: - SseExtensions.cs: 104/104 lines (100.00%) - Overall project: 321/321 lines (100.00%) - Branches: 61/62 (98.39%) Tests: 96 passed (net9.0), 95 passed (net10.0) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent de4d2ad commit 6ef3c80

File tree

3 files changed

+190
-0
lines changed

3 files changed

+190
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using System.Threading.Channels;
2+
3+
namespace SWEN3.Paperless.RabbitMq.Tests.Helpers;
4+
5+
/// <summary>
6+
/// Test-only implementation of ISseStream that can be completed on demand.
7+
/// Uses a Channel internally to enable natural completion of ReadAllAsync.
8+
/// </summary>
9+
internal sealed class FakeCompletableSseStream<T> : ISseStream<T> where T : class
10+
{
11+
private readonly Channel<T> _channel = Channel.CreateUnbounded<T>();
12+
private readonly Dictionary<Guid, ChannelReader<T>> _subscribers = new();
13+
private readonly object _lock = new();
14+
15+
/// <summary>
16+
/// Gets the current number of active subscribers.
17+
/// </summary>
18+
public int ClientCount
19+
{
20+
get
21+
{
22+
lock (_lock)
23+
{
24+
return _subscribers.Count;
25+
}
26+
}
27+
}
28+
29+
/// <summary>
30+
/// Publishes an event to all subscribers.
31+
/// </summary>
32+
public void Publish(T message)
33+
{
34+
_channel.Writer.TryWrite(message);
35+
}
36+
37+
/// <summary>
38+
/// Subscribes a client and returns a channel reader for receiving events.
39+
/// </summary>
40+
public ChannelReader<T> Subscribe(Guid clientId)
41+
{
42+
lock (_lock)
43+
{
44+
_subscribers[clientId] = _channel.Reader;
45+
return _channel.Reader;
46+
}
47+
}
48+
49+
/// <summary>
50+
/// Unsubscribes a client.
51+
/// </summary>
52+
public void Unsubscribe(Guid clientId)
53+
{
54+
lock (_lock)
55+
{
56+
_subscribers.Remove(clientId);
57+
}
58+
}
59+
60+
/// <summary>
61+
/// Completes the stream, causing ReadAllAsync to terminate naturally.
62+
/// </summary>
63+
public void Complete()
64+
{
65+
_channel.Writer.TryComplete();
66+
}
67+
}

SWEN3.Paperless.RabbitMq.Tests/Unit/SseExtensionsFallbackTests.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,5 +265,67 @@ public async Task MapSse_Fallback_CompletesCleanlyWhenClientDisconnects()
265265
await host.StopAsync();
266266
}
267267
}
268+
269+
[Fact]
270+
public async Task MapSse_Fallback_CompletesNaturallyWhenStreamEnds()
271+
{
272+
// Arrange
273+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
274+
cts.CancelAfter(TimeSpan.FromSeconds(30));
275+
276+
var fakeStream = new Helpers.FakeCompletableSseStream<Messages.SseTestEvent>();
277+
278+
var (host, server) = await SseTestHelpers.CreateSseTestServerAsync<Messages.SseTestEvent>(
279+
configureServices: services => services.AddSingleton<ISseStream<Messages.SseTestEvent>>(fakeStream),
280+
configureEndpoints: e => e.MapSse<Messages.SseTestEvent>("/sse",
281+
evt => new { id = evt.Id, msg = evt.Message },
282+
_ => "complete-event"));
283+
284+
using var _ = host;
285+
var client = server.CreateClient();
286+
client.Timeout = Timeout.InfiniteTimeSpan;
287+
288+
// Act - Connect without cancellation token to avoid cancellation-based termination
289+
var responseTask = client.GetAsync("/sse", HttpCompletionOption.ResponseHeadersRead, CancellationToken.None);
290+
291+
// Wait for connection to establish
292+
await Task.Delay(100, cts.Token);
293+
294+
// Publish one event
295+
fakeStream.Publish(new Messages.SseTestEvent { Id = 42, Message = "Final" });
296+
297+
try
298+
{
299+
var response = await responseTask.WaitAsync(cts.Token);
300+
response.EnsureSuccessStatusCode();
301+
302+
await using var stream = await response.Content.ReadAsStreamAsync(cts.Token);
303+
using var reader = new StreamReader(stream, Encoding.UTF8);
304+
305+
// Read the event
306+
var eventLine = await reader.ReadLineAsync(cts.Token);
307+
var dataLine = await reader.ReadLineAsync(cts.Token);
308+
var blankLine = await reader.ReadLineAsync(cts.Token);
309+
310+
eventLine.Should().Be("event: complete-event");
311+
dataLine.Should().Be("data: {\"id\":42,\"msg\":\"Final\"}");
312+
blankLine.Should().BeEmpty();
313+
314+
// Complete the stream to end ReadAllAsync naturally
315+
fakeStream.Complete();
316+
317+
// Verify the stream ends (ReadLineAsync returns null)
318+
var endLine = await reader.ReadLineAsync(cts.Token);
319+
endLine.Should().BeNull("stream should end naturally after Complete()");
320+
321+
// Verify Unsubscribe was called in finally block
322+
await Task.Delay(50, cts.Token); // Allow finally block to execute
323+
fakeStream.ClientCount.Should().Be(0, "finally block should have called Unsubscribe");
324+
}
325+
finally
326+
{
327+
await host.StopAsync();
328+
}
329+
}
268330
#endif
269331
}

SWEN3.Paperless.RabbitMq.Tests/Unit/SseExtensionsNet10Tests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,5 +211,66 @@ public async Task MapSse_Net10_CompletesIteratorWhenClientDisconnects()
211211
await host.StopAsync();
212212
}
213213
}
214+
215+
[Fact]
216+
public async Task MapSse_Net10_CompletesNaturallyWhenStreamEnds()
217+
{
218+
// Arrange
219+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
220+
cts.CancelAfter(TimeSpan.FromSeconds(30));
221+
222+
var fakeStream = new Helpers.FakeCompletableSseStream<Messages.SseTestEvent>();
223+
224+
var (host, server) = await SseTestHelpers.CreateSseTestServerAsync<Messages.SseTestEvent>(
225+
configureServices: services => services.AddSingleton<ISseStream<Messages.SseTestEvent>>(fakeStream),
226+
configureEndpoints: e => e.MapSse<Messages.SseTestEvent>("/sse",
227+
m => new { m.Id, m.Message },
228+
_ => "final-event"));
229+
230+
using var _ = host;
231+
var client = server.CreateClient();
232+
client.Timeout = Timeout.InfiniteTimeSpan;
233+
234+
// Act - Connect without cancellation token to avoid cancellation-based termination
235+
var responseTask = client.GetAsync("/sse", HttpCompletionOption.ResponseHeadersRead, CancellationToken.None);
236+
237+
// Wait for connection to establish
238+
await Task.Delay(100, cts.Token);
239+
240+
// Publish one event
241+
fakeStream.Publish(new Messages.SseTestEvent { Id = 99, Message = "Done" });
242+
243+
try
244+
{
245+
var response = await responseTask.WaitAsync(cts.Token);
246+
response.EnsureSuccessStatusCode();
247+
248+
await using var stream = await response.Content.ReadAsStreamAsync(cts.Token);
249+
using var reader = new StreamReader(stream, Encoding.UTF8);
250+
251+
// Read the event
252+
var eventLine = await reader.ReadLineAsync(cts.Token);
253+
var dataLine = await reader.ReadLineAsync(cts.Token);
254+
var blankLine = await reader.ReadLineAsync(cts.Token);
255+
256+
eventLine.Should().Be("event: final-event");
257+
dataLine.Should().Be("data: {\"id\":99,\"message\":\"Done\"}");
258+
blankLine.Should().BeEmpty();
259+
260+
// Complete the stream to end ReadAllAsync naturally
261+
fakeStream.Complete();
262+
263+
// Verify the stream ends (ReadLineAsync returns null)
264+
var endLine = await reader.ReadLineAsync(cts.Token);
265+
endLine.Should().BeNull("stream should end naturally after Complete()");
266+
267+
// Note: In NET10, RequestAborted handler only fires on actual cancellation,
268+
// not on natural stream completion, so ClientCount stays 1 (expected behavior)
269+
}
270+
finally
271+
{
272+
await host.StopAsync();
273+
}
274+
}
214275
#endif
215276
}

0 commit comments

Comments
 (0)