|
| 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<IssueHub>("/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<object></c> rather than <c>On<IssueDto></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