Skip to content

Commit e5804ab

Browse files
authored
Merge pull request #825 from Chris0Jeky/test/concurrency-race-condition-stress-tests
test: concurrency and race condition stress tests (#705)
2 parents 25e9475 + eee12bf commit e5804ab

7 files changed

Lines changed: 1714 additions & 0 deletions
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
using System.Collections.Concurrent;
2+
using System.Net;
3+
using System.Net.Http.Json;
4+
using FluentAssertions;
5+
using Microsoft.AspNetCore.SignalR.Client;
6+
using Taskdeck.Api.Realtime;
7+
using Taskdeck.Api.Tests.Support;
8+
using Taskdeck.Application.DTOs;
9+
using Taskdeck.Domain.Enums;
10+
using Xunit;
11+
12+
namespace Taskdeck.Api.Tests.Concurrency;
13+
14+
/// <summary>
15+
/// Board presence (SignalR) concurrency tests exercising:
16+
/// - Rapid join/leave stress (multiple connections join and leave rapidly)
17+
/// - Disconnect during edit (editing state cleared on abrupt disconnect)
18+
///
19+
/// These tests validate that presence tracking is eventually consistent
20+
/// under concurrent SignalR operations.
21+
///
22+
/// See GitHub issue #705 (TST-55).
23+
/// </summary>
24+
public class BoardPresenceConcurrencyTests : IClassFixture<TestWebApplicationFactory>
25+
{
26+
private readonly TestWebApplicationFactory _factory;
27+
28+
public BoardPresenceConcurrencyTests(TestWebApplicationFactory factory)
29+
{
30+
_factory = factory;
31+
}
32+
33+
/// <summary>
34+
/// Polls the observer's event collector until a snapshot with the expected
35+
/// member count appears, or the timeout elapses.
36+
/// </summary>
37+
private static async Task<BoardPresenceSnapshot> WaitForPresenceCountAsync(
38+
EventCollector<BoardPresenceSnapshot> events,
39+
int expectedMemberCount,
40+
TimeSpan? timeout = null)
41+
{
42+
var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(10);
43+
var deadline = DateTimeOffset.UtcNow + effectiveTimeout;
44+
while (DateTimeOffset.UtcNow < deadline)
45+
{
46+
var snapshot = events.ToList().LastOrDefault();
47+
if (snapshot is not null && snapshot.Members.Count == expectedMemberCount)
48+
return snapshot;
49+
await Task.Delay(50);
50+
}
51+
52+
var last = events.ToList().LastOrDefault();
53+
var actualCount = last?.Members.Count ?? 0;
54+
throw new TimeoutException(
55+
$"Expected presence snapshot with {expectedMemberCount} members " +
56+
$"but last snapshot had {actualCount} within {effectiveTimeout.TotalSeconds}s.");
57+
}
58+
59+
/// <summary>
60+
/// Scenario: Rapid join/leave stress.
61+
/// Multiple connections rapidly join and leave a board.
62+
/// After all connections settle, the final presence snapshot should be
63+
/// eventually consistent (only connections that remain joined are present).
64+
/// </summary>
65+
[Fact]
66+
public async Task RapidJoinLeave_EventuallyConsistent()
67+
{
68+
const int connectionCount = 5;
69+
70+
using var ownerClient = _factory.CreateClient();
71+
var owner = await ApiTestHarness.AuthenticateAsync(ownerClient, "presence-rapid");
72+
var board = await ApiTestHarness.CreateBoardAsync(ownerClient, "presence-rapid-board");
73+
74+
// Create users and grant access
75+
using var setupClient = _factory.CreateClient();
76+
var users = new List<TestUserContext>();
77+
for (var i = 0; i < connectionCount; i++)
78+
{
79+
var u = await ApiTestHarness.AuthenticateAsync(setupClient, $"presence-rapid-{i}");
80+
var grant = await ownerClient.PostAsJsonAsync(
81+
$"/api/boards/{board.Id}/access",
82+
new GrantAccessDto(board.Id, u.UserId, UserRole.Editor));
83+
grant.StatusCode.Should().Be(HttpStatusCode.OK);
84+
users.Add(u);
85+
}
86+
87+
// Owner observes presence events
88+
var observerEvents = new EventCollector<BoardPresenceSnapshot>();
89+
await using var observer = SignalRTestHelper.CreateBoardsHubConnection(
90+
_factory, owner.Token);
91+
observer.On<BoardPresenceSnapshot>("boardPresence",
92+
snapshot => observerEvents.Add(snapshot));
93+
await observer.StartAsync();
94+
await observer.InvokeAsync("JoinBoard", board.Id);
95+
await SignalRTestHelper.WaitForEventsAsync(observerEvents, 1);
96+
observerEvents.Clear();
97+
98+
// All users join simultaneously via SemaphoreSlim (async-safe,
99+
// unlike Barrier.SignalAndWait which blocks thread-pool threads)
100+
var connections = new List<HubConnection>();
101+
try
102+
{
103+
using var joinBarrier = new SemaphoreSlim(0, connectionCount);
104+
var joinTasks = users.Select(async user =>
105+
{
106+
var conn = SignalRTestHelper.CreateBoardsHubConnection(_factory, user.Token);
107+
conn.On<BoardPresenceSnapshot>("boardPresence", _ => { });
108+
await conn.StartAsync();
109+
lock (connections) { connections.Add(conn); }
110+
await joinBarrier.WaitAsync(TimeSpan.FromSeconds(10));
111+
await conn.InvokeAsync("JoinBoard", board.Id);
112+
}).ToArray();
113+
114+
joinBarrier.Release(connectionCount);
115+
await Task.WhenAll(joinTasks);
116+
117+
// Wait for all joins to settle
118+
var afterJoin = await WaitForPresenceCountAsync(
119+
observerEvents, connectionCount + 1, TimeSpan.FromSeconds(10));
120+
afterJoin.Members.Should().HaveCount(connectionCount + 1,
121+
"all joined users plus the observer owner should be present");
122+
123+
// First half leave rapidly
124+
observerEvents.Clear();
125+
var leavingCount = connectionCount / 2;
126+
using var leaveBarrier = new SemaphoreSlim(0, leavingCount);
127+
var leaveTasks = connections.Take(leavingCount).Select(async conn =>
128+
{
129+
await leaveBarrier.WaitAsync(TimeSpan.FromSeconds(10));
130+
await conn.InvokeAsync("LeaveBoard", board.Id);
131+
}).ToArray();
132+
133+
leaveBarrier.Release(leavingCount);
134+
await Task.WhenAll(leaveTasks);
135+
136+
// Wait for leaves to settle
137+
var remaining = connectionCount - leavingCount;
138+
var afterLeave = await WaitForPresenceCountAsync(
139+
observerEvents, remaining + 1, TimeSpan.FromSeconds(10));
140+
afterLeave.Members.Should().HaveCount(remaining + 1,
141+
$"after {leavingCount} leaves, {remaining} users + owner should remain");
142+
}
143+
finally
144+
{
145+
foreach (var conn in connections)
146+
await conn.DisposeAsync();
147+
}
148+
}
149+
150+
/// <summary>
151+
/// Scenario: Disconnect during edit clears editing state.
152+
/// A user sets an editing card, then their connection drops abruptly.
153+
/// The presence snapshot should no longer include the editing state.
154+
/// </summary>
155+
[Fact]
156+
public async Task DisconnectDuringEdit_ClearsEditingState()
157+
{
158+
using var ownerClient = _factory.CreateClient();
159+
var owner = await ApiTestHarness.AuthenticateAsync(ownerClient, "presence-disc-edit");
160+
var board = await ApiTestHarness.CreateBoardAsync(ownerClient, "presence-disc-board");
161+
162+
// Create a column and card
163+
var colResp = await ownerClient.PostAsJsonAsync(
164+
$"/api/boards/{board.Id}/columns",
165+
new CreateColumnDto(board.Id, "Backlog", null, null));
166+
colResp.StatusCode.Should().Be(HttpStatusCode.Created);
167+
var col = await colResp.Content.ReadFromJsonAsync<ColumnDto>();
168+
169+
var cardResp = await ownerClient.PostAsJsonAsync(
170+
$"/api/boards/{board.Id}/cards",
171+
new CreateCardDto(board.Id, col!.Id, "Disconnect card", null, null, null));
172+
cardResp.StatusCode.Should().Be(HttpStatusCode.Created);
173+
var card = await cardResp.Content.ReadFromJsonAsync<CardDto>();
174+
175+
// Second user who will disconnect
176+
using var editorClient = _factory.CreateClient();
177+
var editor = await ApiTestHarness.AuthenticateAsync(editorClient, "presence-disc-editor");
178+
var grant = await ownerClient.PostAsJsonAsync(
179+
$"/api/boards/{board.Id}/access",
180+
new GrantAccessDto(board.Id, editor.UserId, UserRole.Editor));
181+
grant.StatusCode.Should().Be(HttpStatusCode.OK);
182+
183+
// Owner joins and observes
184+
var observerEvents = new EventCollector<BoardPresenceSnapshot>();
185+
await using var observer = SignalRTestHelper.CreateBoardsHubConnection(
186+
_factory, owner.Token);
187+
observer.On<BoardPresenceSnapshot>("boardPresence",
188+
snapshot => observerEvents.Add(snapshot));
189+
await observer.StartAsync();
190+
await observer.InvokeAsync("JoinBoard", board.Id);
191+
await SignalRTestHelper.WaitForEventsAsync(observerEvents, 1);
192+
193+
// Editor joins and starts editing
194+
await using var editorConn = SignalRTestHelper.CreateBoardsHubConnection(
195+
_factory, editor.Token);
196+
editorConn.On<BoardPresenceSnapshot>("boardPresence", _ => { });
197+
await editorConn.StartAsync();
198+
await editorConn.InvokeAsync("JoinBoard", board.Id);
199+
await SignalRTestHelper.WaitForEventsAsync(observerEvents, 2);
200+
201+
observerEvents.Clear();
202+
await editorConn.InvokeAsync("SetEditingCard", board.Id, card!.Id);
203+
204+
// Wait for editing presence update
205+
var editingEvents = await SignalRTestHelper.WaitForEventsAsync(observerEvents, 1);
206+
var editorMember = editingEvents.Last().Members
207+
.FirstOrDefault(m => m.UserId == editor.UserId);
208+
editorMember.Should().NotBeNull("editor should be visible in presence");
209+
editorMember!.EditingCardId.Should().Be(card.Id,
210+
"editor should show as editing the card");
211+
212+
// Abrupt disconnect (no LeaveBoard, no SetEditingCard(null))
213+
observerEvents.Clear();
214+
await editorConn.DisposeAsync();
215+
216+
// Owner should receive a snapshot without the editor
217+
var afterDisconnect = await SignalRTestHelper.WaitForEventsAsync(
218+
observerEvents, 1, TimeSpan.FromSeconds(5));
219+
afterDisconnect.Last().Members.Should().NotContain(
220+
m => m.UserId == editor.UserId,
221+
"disconnected editor should be removed from presence");
222+
afterDisconnect.Last().Members.Should().ContainSingle(
223+
m => m.UserId == owner.UserId,
224+
"only the owner should remain after editor disconnects");
225+
}
226+
}

0 commit comments

Comments
 (0)