Skip to content

Commit 8c68819

Browse files
mpauloskyCopilot
andauthored
test(coverage): Sprint 2 — +98 tests for storage, email, SignalR, labels, converters (#212)
* test(web): add SendGridEmailService unit tests (S2-2, #200) - Add internal testing constructor to SendGridEmailService accepting ISendGridClient - Change private field from SendGridClient to ISendGridClient for testability - Add InternalsVisibleTo(Web.Tests) to Web.csproj - Add 51 unit tests covering SendAsync (success, non-2xx, exception) and SendTemplatedAsync (success, failure, logging) with NSubstitute mocks - Fix pre-existing FluentAssertions 8.x incompatibility in LocalFileStorageServiceTests (BeLessOrEqualTo → BeLessThanOrEqualTo) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(web): make SendGridEmailService testable via ISendGridClient (S2-2) - Change private field from SendGridClient to ISendGridClient - Add internal testing constructor accepting ISendGridClient - Add InternalsVisibleTo(Web.Tests) to Web.csproj - Pick up csproj dependency updates from concurrent agent work Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): remove duplicate DownloadAsync test from LocalFileStorageServiceTests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(integration): add LabelEndpoints integration tests (S2-3, #201) - 11 tests covering GET /api/labels/suggestions?prefix={prefix}&max={max} - 401 for unauthenticated access - 400 for empty/whitespace prefix (missing param and explicit empty value) - 400 error body contains 'Prefix cannot be empty' when prefix='' - 200 returns labels that start with prefix (case-insensitive, verified) - 200 returns empty list when no labels match prefix - max parameter correctly truncates to exact count - Default max=10 verified with 15-label seed dataset - Distinct labels across multiple issues sharing same label - SeedIssueWithLabelsAsync helper for direct DB label population Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: set Queued status before writing to channel in InMemoryBulkOperationQueue Race condition: background service could process item and set terminal status (Failed/Completed) before QueueAsync set initial Queued status, overwriting the terminal status and causing flaky test failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ece993c commit 8c68819

11 files changed

Lines changed: 1731 additions & 4 deletions

src/Web/Services/InMemoryBulkOperationQueue.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,12 @@ public async Task<string> QueueAsync<T>(T command, CancellationToken cancellatio
5454
typeof(T).Name,
5555
DateTime.UtcNow);
5656

57-
await _channel.Writer.WriteAsync(queuedOperation, cancellationToken);
58-
59-
// Store initial status
57+
// Store initial status before writing to channel to avoid race condition
58+
// where background service processes and sets terminal status before Queued is set
6059
await UpdateStatusAsync(operationId, BulkOperationStatus.Queued, null, cancellationToken);
6160

61+
await _channel.Writer.WriteAsync(queuedOperation, cancellationToken);
62+
6263
_logger.LogInformation(
6364
"Queued bulk operation {OperationId} of type {Type}",
6465
operationId,

src/Web/Services/SendGridEmailService.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public sealed class SendGridEmailService : IEmailService
2323
{
2424
private readonly SendGridSettings _settings;
2525
private readonly ILogger<SendGridEmailService> _logger;
26-
private readonly SendGridClient _client;
26+
private readonly ISendGridClient _client;
2727

2828
public SendGridEmailService(
2929
IOptions<SendGridSettings> settings,
@@ -34,6 +34,19 @@ public SendGridEmailService(
3434
_client = new SendGridClient(_settings.ApiKey);
3535
}
3636

37+
/// <summary>
38+
/// Internal constructor for unit testing — accepts a pre-configured <see cref="ISendGridClient" />.
39+
/// </summary>
40+
internal SendGridEmailService(
41+
IOptions<SendGridSettings> settings,
42+
ILogger<SendGridEmailService> logger,
43+
ISendGridClient client)
44+
{
45+
_settings = settings.Value;
46+
_logger = logger;
47+
_client = client;
48+
}
49+
3750
public async Task<Result> SendAsync(EmailMessage message, CancellationToken ct = default)
3851
{
3952
try

src/Web/Web.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
<PackageReference Include="SixLabors.ImageSharp" />
3030
</ItemGroup>
3131

32+
<ItemGroup>
33+
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
34+
<_Parameter1>Web.Tests</_Parameter1>
35+
</AssemblyAttribute>
36+
</ItemGroup>
37+
3238
<!-- TailwindCSS Build Integration -->
3339
<Target Name="CheckNodeModules" BeforeTargets="BuildTailwindCSS">
3440
<Message Importance="high" Text="Checking for node_modules..." />
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// =======================================================
2+
// Copyright (c) 2025. All rights reserved.
3+
// File Name : IssueHubTests.cs
4+
// Company : mpaulosky
5+
// Author : Matthew Paulosky
6+
// Solution Name : IssueTrackerApp
7+
// Project Name : Web.Tests.Integration
8+
// =======================================================
9+
10+
using System.Text.Json;
11+
12+
using Domain.DTOs;
13+
14+
using Microsoft.AspNetCore.Http.Connections;
15+
using Microsoft.AspNetCore.SignalR.Client;
16+
17+
using MongoDB.Bson;
18+
19+
namespace Web.Tests.Integration;
20+
21+
/// <summary>
22+
/// Integration tests for the <see cref="IssueHub"/> SignalR hub.
23+
/// Verifies real-time connection lifecycle and group membership behaviour
24+
/// through a full ASP.NET Core test host.
25+
/// </summary>
26+
[Collection("Integration")]
27+
public sealed class IssueHubTests : IntegrationTestBase
28+
{
29+
/// <summary>Hub path as registered in Program.cs via <c>app.MapHub&lt;IssueHub&gt;("/hubs/issues")</c>.</summary>
30+
private const string HubPath = "hubs/issues";
31+
32+
public IssueHubTests(CustomWebApplicationFactory factory) : base(factory)
33+
{
34+
}
35+
36+
// -----------------------------------------------------------------------
37+
// Helper: build a HubConnection that uses the in-process TestServer
38+
// -----------------------------------------------------------------------
39+
40+
/// <summary>
41+
/// Creates a <see cref="HubConnection"/> wired to the test host.
42+
/// LongPolling is used because it is the most reliable transport through
43+
/// the TestServer HTTP message handler (no real TCP socket required).
44+
/// </summary>
45+
private HubConnection CreateHubConnection()
46+
{
47+
// Server.BaseAddress ends with "/", e.g. "http://localhost/"
48+
var hubUrl = new Uri(Factory.Server.BaseAddress, HubPath).ToString();
49+
50+
return new HubConnectionBuilder()
51+
.WithUrl(hubUrl, options =>
52+
{
53+
// Route all HTTP traffic through the in-memory test server
54+
options.HttpMessageHandlerFactory = _ => Factory.Server.CreateHandler();
55+
56+
// LongPolling is the most portable transport for TestHost
57+
options.Transports = HttpTransportType.LongPolling;
58+
})
59+
.Build();
60+
}
61+
62+
// -----------------------------------------------------------------------
63+
// Test 1 – connect
64+
// -----------------------------------------------------------------------
65+
66+
/// <summary>
67+
/// Connecting to the hub should succeed and the connection should reach
68+
/// <see cref="HubConnectionState.Connected"/>.
69+
/// OnConnectedAsync adds the client to the "all" group; the fact that
70+
/// StartAsync completes without throwing confirms the group-add path ran.
71+
/// </summary>
72+
[Fact]
73+
public async Task Connect_AddsClientToAllGroup_WhenConnected()
74+
{
75+
// Arrange
76+
var connection = CreateHubConnection();
77+
78+
try
79+
{
80+
// Act
81+
await connection.StartAsync();
82+
83+
// Assert
84+
connection.State.Should().Be(HubConnectionState.Connected);
85+
}
86+
finally
87+
{
88+
await connection.StopAsync();
89+
await connection.DisposeAsync();
90+
}
91+
}
92+
93+
// -----------------------------------------------------------------------
94+
// Test 2 – JoinIssueGroup
95+
// -----------------------------------------------------------------------
96+
97+
/// <summary>
98+
/// Invoking <c>JoinIssueGroup</c> on a connected hub should complete
99+
/// without throwing.
100+
/// </summary>
101+
[Fact]
102+
public async Task JoinIssueGroup_Succeeds_WhenConnected()
103+
{
104+
// Arrange
105+
var connection = CreateHubConnection();
106+
107+
try
108+
{
109+
await connection.StartAsync();
110+
111+
// Act & Assert – no exception expected
112+
var act = async () => await connection.InvokeAsync("JoinIssueGroup", "issue-123");
113+
await act.Should().NotThrowAsync();
114+
}
115+
finally
116+
{
117+
await connection.StopAsync();
118+
await connection.DisposeAsync();
119+
}
120+
}
121+
122+
// -----------------------------------------------------------------------
123+
// Test 3 – LeaveIssueGroup
124+
// -----------------------------------------------------------------------
125+
126+
/// <summary>
127+
/// Invoking <c>LeaveIssueGroup</c> after joining should complete without
128+
/// throwing even when the client is no longer in the group.
129+
/// </summary>
130+
[Fact]
131+
public async Task LeaveIssueGroup_Succeeds_WhenConnected()
132+
{
133+
// Arrange
134+
var connection = CreateHubConnection();
135+
136+
try
137+
{
138+
await connection.StartAsync();
139+
await connection.InvokeAsync("JoinIssueGroup", "issue-456");
140+
141+
// Act & Assert – no exception expected
142+
var act = async () => await connection.InvokeAsync("LeaveIssueGroup", "issue-456");
143+
await act.Should().NotThrowAsync();
144+
}
145+
finally
146+
{
147+
await connection.StopAsync();
148+
await connection.DisposeAsync();
149+
}
150+
}
151+
152+
// -----------------------------------------------------------------------
153+
// Test 4 – disconnect
154+
// -----------------------------------------------------------------------
155+
156+
/// <summary>
157+
/// Stopping a connected hub connection should move it to
158+
/// <see cref="HubConnectionState.Disconnected"/> cleanly.
159+
/// </summary>
160+
[Fact]
161+
public async Task Disconnect_Completes_WhenConnectionStopped()
162+
{
163+
// Arrange
164+
var connection = CreateHubConnection();
165+
166+
try
167+
{
168+
await connection.StartAsync();
169+
connection.State.Should().Be(HubConnectionState.Connected);
170+
171+
// Act
172+
await connection.StopAsync();
173+
174+
// Assert
175+
connection.State.Should().Be(HubConnectionState.Disconnected);
176+
}
177+
finally
178+
{
179+
await connection.DisposeAsync();
180+
}
181+
}
182+
183+
// -----------------------------------------------------------------------
184+
// Test 5 (bonus) – IssueVoted broadcast
185+
// -----------------------------------------------------------------------
186+
187+
/// <summary>
188+
/// When a vote is cast via the REST endpoint the hub should broadcast an
189+
/// <c>IssueVoted</c> message to all connected clients.
190+
/// <para>
191+
/// This test verifies the end-to-end flow:
192+
/// REST POST → VoteEndpoints → IHubContext.Clients.All.SendAsync → client receives event.
193+
/// </para>
194+
/// <para>
195+
/// We use <c>On&lt;object&gt;</c> rather than <c>On&lt;IssueDto&gt;</c> because the
196+
/// server's default SignalR JSON serialisation does not include the custom
197+
/// <see cref="ObjectIdJsonConverter"/> needed to round-trip <see cref="MongoDB.Bson.ObjectId"/>.
198+
/// For this test we only care that the broadcast arrives; payload validation of the
199+
/// HTTP response covers the DTO content.
200+
/// </para>
201+
/// </summary>
202+
[Fact]
203+
public async Task IssueVoted_EventReceived_WhenVoteIsCastViaApi()
204+
{
205+
// ── Arrange ──────────────────────────────────────────────────────────
206+
var (categories, statuses) = await SeedTestDataAsync();
207+
var issue = await SeedIssueAsync(categories[0], statuses[0], "Hub Vote Test Issue");
208+
var issueId = issue.Id.ToString();
209+
210+
var connection = CreateHubConnection();
211+
212+
// TCS<bool> – avoids any ObjectId deserialisation in the hub handler.
213+
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
214+
215+
try
216+
{
217+
await connection.StartAsync();
218+
connection.State.Should().Be(HubConnectionState.Connected);
219+
220+
// Register listener BEFORE casting the vote so we cannot miss the event
221+
connection.On<object>("IssueVoted", _ => tcs.TrySetResult(true));
222+
223+
// Authenticated client (User role) satisfies the "UserPolicy" on VoteEndpoints
224+
using var authClient = CreateAuthenticatedClient("User");
225+
226+
// Act – cast a vote; this triggers Clients.All.SendAsync("IssueVoted", ...)
227+
var response = await authClient.PostAsync($"/api/issues/{issueId}/vote", content: null);
228+
response.IsSuccessStatusCode.Should().BeTrue(
229+
because: $"POST /api/issues/{issueId}/vote should succeed but returned {response.StatusCode}: " +
230+
$"{await response.Content.ReadAsStringAsync()}");
231+
232+
// Assert – the broadcast should arrive within 5 seconds
233+
var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(5)));
234+
completedTask.Should().Be(tcs.Task,
235+
because: "IssueVoted SignalR broadcast should have been received within 5 seconds");
236+
}
237+
finally
238+
{
239+
await connection.StopAsync();
240+
await connection.DisposeAsync();
241+
}
242+
}
243+
}

0 commit comments

Comments
 (0)