diff --git a/backend/tests/Taskdeck.Api.Tests/Concurrency/BoardPresenceConcurrencyTests.cs b/backend/tests/Taskdeck.Api.Tests/Concurrency/BoardPresenceConcurrencyTests.cs
new file mode 100644
index 00000000..daae5048
--- /dev/null
+++ b/backend/tests/Taskdeck.Api.Tests/Concurrency/BoardPresenceConcurrencyTests.cs
@@ -0,0 +1,226 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Http.Json;
+using FluentAssertions;
+using Microsoft.AspNetCore.SignalR.Client;
+using Taskdeck.Api.Realtime;
+using Taskdeck.Api.Tests.Support;
+using Taskdeck.Application.DTOs;
+using Taskdeck.Domain.Enums;
+using Xunit;
+
+namespace Taskdeck.Api.Tests.Concurrency;
+
+///
+/// Board presence (SignalR) concurrency tests exercising:
+/// - Rapid join/leave stress (multiple connections join and leave rapidly)
+/// - Disconnect during edit (editing state cleared on abrupt disconnect)
+///
+/// These tests validate that presence tracking is eventually consistent
+/// under concurrent SignalR operations.
+///
+/// See GitHub issue #705 (TST-55).
+///
+public class BoardPresenceConcurrencyTests : IClassFixture
+{
+ private readonly TestWebApplicationFactory _factory;
+
+ public BoardPresenceConcurrencyTests(TestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Polls the observer's event collector until a snapshot with the expected
+ /// member count appears, or the timeout elapses.
+ ///
+ private static async Task WaitForPresenceCountAsync(
+ EventCollector events,
+ int expectedMemberCount,
+ TimeSpan? timeout = null)
+ {
+ var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(10);
+ var deadline = DateTimeOffset.UtcNow + effectiveTimeout;
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ var snapshot = events.ToList().LastOrDefault();
+ if (snapshot is not null && snapshot.Members.Count == expectedMemberCount)
+ return snapshot;
+ await Task.Delay(50);
+ }
+
+ var last = events.ToList().LastOrDefault();
+ var actualCount = last?.Members.Count ?? 0;
+ throw new TimeoutException(
+ $"Expected presence snapshot with {expectedMemberCount} members " +
+ $"but last snapshot had {actualCount} within {effectiveTimeout.TotalSeconds}s.");
+ }
+
+ ///
+ /// Scenario: Rapid join/leave stress.
+ /// Multiple connections rapidly join and leave a board.
+ /// After all connections settle, the final presence snapshot should be
+ /// eventually consistent (only connections that remain joined are present).
+ ///
+ [Fact]
+ public async Task RapidJoinLeave_EventuallyConsistent()
+ {
+ const int connectionCount = 5;
+
+ using var ownerClient = _factory.CreateClient();
+ var owner = await ApiTestHarness.AuthenticateAsync(ownerClient, "presence-rapid");
+ var board = await ApiTestHarness.CreateBoardAsync(ownerClient, "presence-rapid-board");
+
+ // Create users and grant access
+ using var setupClient = _factory.CreateClient();
+ var users = new List();
+ for (var i = 0; i < connectionCount; i++)
+ {
+ var u = await ApiTestHarness.AuthenticateAsync(setupClient, $"presence-rapid-{i}");
+ var grant = await ownerClient.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/access",
+ new GrantAccessDto(board.Id, u.UserId, UserRole.Editor));
+ grant.StatusCode.Should().Be(HttpStatusCode.OK);
+ users.Add(u);
+ }
+
+ // Owner observes presence events
+ var observerEvents = new EventCollector();
+ await using var observer = SignalRTestHelper.CreateBoardsHubConnection(
+ _factory, owner.Token);
+ observer.On("boardPresence",
+ snapshot => observerEvents.Add(snapshot));
+ await observer.StartAsync();
+ await observer.InvokeAsync("JoinBoard", board.Id);
+ await SignalRTestHelper.WaitForEventsAsync(observerEvents, 1);
+ observerEvents.Clear();
+
+ // All users join simultaneously via SemaphoreSlim (async-safe,
+ // unlike Barrier.SignalAndWait which blocks thread-pool threads)
+ var connections = new List();
+ try
+ {
+ using var joinBarrier = new SemaphoreSlim(0, connectionCount);
+ var joinTasks = users.Select(async user =>
+ {
+ var conn = SignalRTestHelper.CreateBoardsHubConnection(_factory, user.Token);
+ conn.On("boardPresence", _ => { });
+ await conn.StartAsync();
+ lock (connections) { connections.Add(conn); }
+ await joinBarrier.WaitAsync(TimeSpan.FromSeconds(10));
+ await conn.InvokeAsync("JoinBoard", board.Id);
+ }).ToArray();
+
+ joinBarrier.Release(connectionCount);
+ await Task.WhenAll(joinTasks);
+
+ // Wait for all joins to settle
+ var afterJoin = await WaitForPresenceCountAsync(
+ observerEvents, connectionCount + 1, TimeSpan.FromSeconds(10));
+ afterJoin.Members.Should().HaveCount(connectionCount + 1,
+ "all joined users plus the observer owner should be present");
+
+ // First half leave rapidly
+ observerEvents.Clear();
+ var leavingCount = connectionCount / 2;
+ using var leaveBarrier = new SemaphoreSlim(0, leavingCount);
+ var leaveTasks = connections.Take(leavingCount).Select(async conn =>
+ {
+ await leaveBarrier.WaitAsync(TimeSpan.FromSeconds(10));
+ await conn.InvokeAsync("LeaveBoard", board.Id);
+ }).ToArray();
+
+ leaveBarrier.Release(leavingCount);
+ await Task.WhenAll(leaveTasks);
+
+ // Wait for leaves to settle
+ var remaining = connectionCount - leavingCount;
+ var afterLeave = await WaitForPresenceCountAsync(
+ observerEvents, remaining + 1, TimeSpan.FromSeconds(10));
+ afterLeave.Members.Should().HaveCount(remaining + 1,
+ $"after {leavingCount} leaves, {remaining} users + owner should remain");
+ }
+ finally
+ {
+ foreach (var conn in connections)
+ await conn.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Scenario: Disconnect during edit clears editing state.
+ /// A user sets an editing card, then their connection drops abruptly.
+ /// The presence snapshot should no longer include the editing state.
+ ///
+ [Fact]
+ public async Task DisconnectDuringEdit_ClearsEditingState()
+ {
+ using var ownerClient = _factory.CreateClient();
+ var owner = await ApiTestHarness.AuthenticateAsync(ownerClient, "presence-disc-edit");
+ var board = await ApiTestHarness.CreateBoardAsync(ownerClient, "presence-disc-board");
+
+ // Create a column and card
+ var colResp = await ownerClient.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var col = await colResp.Content.ReadFromJsonAsync();
+
+ var cardResp = await ownerClient.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/cards",
+ new CreateCardDto(board.Id, col!.Id, "Disconnect card", null, null, null));
+ cardResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var card = await cardResp.Content.ReadFromJsonAsync();
+
+ // Second user who will disconnect
+ using var editorClient = _factory.CreateClient();
+ var editor = await ApiTestHarness.AuthenticateAsync(editorClient, "presence-disc-editor");
+ var grant = await ownerClient.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/access",
+ new GrantAccessDto(board.Id, editor.UserId, UserRole.Editor));
+ grant.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ // Owner joins and observes
+ var observerEvents = new EventCollector();
+ await using var observer = SignalRTestHelper.CreateBoardsHubConnection(
+ _factory, owner.Token);
+ observer.On("boardPresence",
+ snapshot => observerEvents.Add(snapshot));
+ await observer.StartAsync();
+ await observer.InvokeAsync("JoinBoard", board.Id);
+ await SignalRTestHelper.WaitForEventsAsync(observerEvents, 1);
+
+ // Editor joins and starts editing
+ await using var editorConn = SignalRTestHelper.CreateBoardsHubConnection(
+ _factory, editor.Token);
+ editorConn.On("boardPresence", _ => { });
+ await editorConn.StartAsync();
+ await editorConn.InvokeAsync("JoinBoard", board.Id);
+ await SignalRTestHelper.WaitForEventsAsync(observerEvents, 2);
+
+ observerEvents.Clear();
+ await editorConn.InvokeAsync("SetEditingCard", board.Id, card!.Id);
+
+ // Wait for editing presence update
+ var editingEvents = await SignalRTestHelper.WaitForEventsAsync(observerEvents, 1);
+ var editorMember = editingEvents.Last().Members
+ .FirstOrDefault(m => m.UserId == editor.UserId);
+ editorMember.Should().NotBeNull("editor should be visible in presence");
+ editorMember!.EditingCardId.Should().Be(card.Id,
+ "editor should show as editing the card");
+
+ // Abrupt disconnect (no LeaveBoard, no SetEditingCard(null))
+ observerEvents.Clear();
+ await editorConn.DisposeAsync();
+
+ // Owner should receive a snapshot without the editor
+ var afterDisconnect = await SignalRTestHelper.WaitForEventsAsync(
+ observerEvents, 1, TimeSpan.FromSeconds(5));
+ afterDisconnect.Last().Members.Should().NotContain(
+ m => m.UserId == editor.UserId,
+ "disconnected editor should be removed from presence");
+ afterDisconnect.Last().Members.Should().ContainSingle(
+ m => m.UserId == owner.UserId,
+ "only the owner should remain after editor disconnects");
+ }
+}
diff --git a/backend/tests/Taskdeck.Api.Tests/Concurrency/CardUpdateConflictTests.cs b/backend/tests/Taskdeck.Api.Tests/Concurrency/CardUpdateConflictTests.cs
new file mode 100644
index 00000000..28e32800
--- /dev/null
+++ b/backend/tests/Taskdeck.Api.Tests/Concurrency/CardUpdateConflictTests.cs
@@ -0,0 +1,327 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Http.Json;
+using FluentAssertions;
+using Taskdeck.Api.Tests.Support;
+using Taskdeck.Application.DTOs;
+using Xunit;
+
+namespace Taskdeck.Api.Tests.Concurrency;
+
+///
+/// Card update conflict tests exercising:
+/// 4. Concurrent card moves to different columns
+/// 5. Concurrent card edits with stale-write detection (ExpectedUpdatedAt)
+/// 6. Column reorder race (two users reorder simultaneously)
+///
+/// Uses Task.WhenAll with SemaphoreSlim barriers for truly simultaneous execution.
+///
+/// NOTE: SQLite serializes writes at the file level. The application-layer
+/// guards (optimistic concurrency via ExpectedUpdatedAt, status checks) are
+/// what these tests validate. With SQLite, concurrent writes serialize, so
+/// "last-writer-wins" behavior may differ from PostgreSQL row-level locking.
+///
+/// See GitHub issue #705 (TST-55).
+///
+public class CardUpdateConflictTests : IClassFixture
+{
+ private readonly TestWebApplicationFactory _factory;
+
+ public CardUpdateConflictTests(TestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Scenario 4: Two concurrent card moves to different columns.
+ /// Both may succeed under SQLite serialization (last-writer-wins),
+ /// but the card must end up in exactly one column.
+ ///
+ [Fact]
+ public async Task ConcurrentMoves_ToDifferentColumns_CardEndsInExactlyOneColumn()
+ {
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "card-move-race");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "card-move-board");
+
+ // Create three columns
+ var col1Resp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Todo", 0, null));
+ col1Resp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var col1 = await col1Resp.Content.ReadFromJsonAsync();
+
+ var col2Resp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "InProgress", 1, null));
+ col2Resp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var col2 = await col2Resp.Content.ReadFromJsonAsync();
+
+ var col3Resp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Done", 2, null));
+ col3Resp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var col3 = await col3Resp.Content.ReadFromJsonAsync();
+
+ // Create a card in col1
+ var cardResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/cards",
+ new CreateCardDto(board.Id, col1!.Id, "Move race card", null, null, null));
+ cardResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var card = await cardResp.Content.ReadFromJsonAsync();
+
+ // Move to col2 and col3 simultaneously
+ using var barrier = new SemaphoreSlim(0, 2);
+ var statusCodes = new ConcurrentBag();
+
+ var moveTargets = new[] { col2!.Id, col3!.Id };
+ var moveTasks = moveTargets.Select(async targetColId =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/cards/{card!.Id}/move",
+ new MoveCardDto(targetColId, 0));
+ statusCodes.Add(resp.StatusCode);
+ }).ToArray();
+
+ barrier.Release(2);
+ await Task.WhenAll(moveTasks);
+
+ // At least one should succeed
+ statusCodes.Should().Contain(HttpStatusCode.OK,
+ "at least one concurrent move should succeed");
+
+ // Verify card is in exactly one column after the race
+ var finalCardResp = await client.GetAsync($"/api/boards/{board.Id}/cards");
+ finalCardResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var allCards = await finalCardResp.Content.ReadFromJsonAsync>();
+ var movedCard = allCards!.Single(c => c.Id == card!.Id);
+ new[] { col2.Id, col3.Id }.Should().Contain(movedCard.ColumnId,
+ "card should end in one of the two target columns");
+ }
+
+ ///
+ /// Scenario 5: Concurrent card edits with stale-write detection.
+ /// Two clients read the same card, then both try to update it using
+ /// the same ExpectedUpdatedAt. The second update should be rejected
+ /// with 409 Conflict.
+ ///
+ [Fact]
+ public async Task ConcurrentEdits_WithExpectedUpdatedAt_SecondUpdateGets409()
+ {
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "card-edit-stale");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "card-edit-board");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var col = await colResp.Content.ReadFromJsonAsync();
+
+ var cardResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/cards",
+ new CreateCardDto(board.Id, col!.Id, "Stale write card", null, null, null));
+ cardResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var card = await cardResp.Content.ReadFromJsonAsync();
+
+ // Both clients read the card at the same time (same UpdatedAt)
+ var originalUpdatedAt = card!.UpdatedAt;
+
+ // First update succeeds
+ var firstUpdate = await client.PatchAsJsonAsync(
+ $"/api/boards/{board.Id}/cards/{card.Id}",
+ new UpdateCardDto("First edit", null, null, null, null, null, originalUpdatedAt));
+ firstUpdate.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ // Second update with stale timestamp should get 409
+ using var staleClient = _factory.CreateClient();
+ staleClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+
+ var secondUpdate = await staleClient.PatchAsJsonAsync(
+ $"/api/boards/{board.Id}/cards/{card.Id}",
+ new UpdateCardDto("Stale edit", null, null, null, null, null, originalUpdatedAt));
+ secondUpdate.StatusCode.Should().Be(HttpStatusCode.Conflict,
+ "second update with stale ExpectedUpdatedAt should be rejected");
+ }
+
+ ///
+ /// Scenario 5b: Concurrent card edits WITHOUT stale-write detection.
+ /// When ExpectedUpdatedAt is not supplied, both updates should succeed
+ /// (last-writer-wins). The card title should reflect one of the updates.
+ ///
+ [Fact]
+ public async Task ConcurrentEdits_WithoutStaleCheck_LastWriterWins()
+ {
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "card-edit-lww");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "card-lww-board");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var col = await colResp.Content.ReadFromJsonAsync();
+
+ var cardResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/cards",
+ new CreateCardDto(board.Id, col!.Id, "LWW card", null, null, null));
+ cardResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var card = await cardResp.Content.ReadFromJsonAsync();
+
+ // Two concurrent updates without ExpectedUpdatedAt
+ using var barrier = new SemaphoreSlim(0, 2);
+ var statusCodes = new ConcurrentBag();
+ var titles = new[] { "Update-Alpha", "Update-Beta" };
+
+ var tasks = titles.Select(async title =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PatchAsJsonAsync(
+ $"/api/boards/{board.Id}/cards/{card!.Id}",
+ new UpdateCardDto(title, null, null, null, null, null));
+ statusCodes.Add(resp.StatusCode);
+ }).ToArray();
+
+ barrier.Release(2);
+ await Task.WhenAll(tasks);
+
+ // Both should succeed (no concurrency guard without ExpectedUpdatedAt)
+ statusCodes.Should().AllSatisfy(s =>
+ s.Should().Be(HttpStatusCode.OK),
+ "updates without ExpectedUpdatedAt should succeed (last-writer-wins)");
+
+ // Card should have one of the two titles
+ var finalResp = await client.GetAsync($"/api/boards/{board.Id}/cards");
+ var allCards = await finalResp.Content.ReadFromJsonAsync>();
+ var finalCard = allCards!.Single(c => c.Id == card!.Id);
+ finalCard.Title.Should().BeOneOf("Update-Alpha", "Update-Beta");
+ }
+
+ ///
+ /// Scenario 6: Column reorder race.
+ /// Two clients reorder columns at the same time. The board should end up
+ /// with consistent column positions (no duplicates, no gaps).
+ ///
+ [Fact]
+ public async Task ColumnReorder_ConcurrentReorders_ConsistentFinalState()
+ {
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "col-reorder-race");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "col-reorder-board");
+
+ // Create three columns
+ var colIds = new List();
+ for (var i = 0; i < 3; i++)
+ {
+ var resp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, $"Col-{i}", i, null));
+ resp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var col = await resp.Content.ReadFromJsonAsync();
+ colIds.Add(col!.Id);
+ }
+
+ // Two clients send different reorder sequences simultaneously
+ using var barrier = new SemaphoreSlim(0, 2);
+ var order1 = new List { colIds[2], colIds[0], colIds[1] };
+ var order2 = new List { colIds[1], colIds[2], colIds[0] };
+ var statusCodes = new ConcurrentBag();
+
+ var reorderTasks = new[] { order1, order2 }.Select(async order =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns/reorder",
+ new ReorderColumnsDto(order));
+ statusCodes.Add(resp.StatusCode);
+ }).ToArray();
+
+ barrier.Release(2);
+ await Task.WhenAll(reorderTasks);
+
+ // At least one should succeed
+ statusCodes.Should().Contain(HttpStatusCode.OK,
+ "at least one reorder should succeed");
+
+ // Verify columns have distinct positions (no duplicates)
+ var colsResp = await client.GetAsync($"/api/boards/{board.Id}/columns");
+ colsResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var columns = await colsResp.Content.ReadFromJsonAsync>();
+ columns.Should().HaveCount(3);
+ columns!.Select(c => c.Position).Distinct().Should().HaveCount(3,
+ "column positions should be unique after concurrent reorders");
+ }
+
+ ///
+ /// Scenario 6b: Concurrent card creation in the same column.
+ /// Multiple users create cards in the same column simultaneously.
+ /// All cards should be created with no duplicates or losses.
+ ///
+ [Fact]
+ public async Task ConcurrentCardCreation_SameColumn_AllCreatedNoDuplicates()
+ {
+ const int cardCount = 5;
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "card-create-race");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "card-create-board");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var col = await colResp.Content.ReadFromJsonAsync();
+
+ // Create cards concurrently
+ using var barrier = new SemaphoreSlim(0, cardCount);
+ var statusCodes = new ConcurrentBag();
+ var createdIds = new ConcurrentBag();
+
+ var tasks = Enumerable.Range(0, cardCount).Select(async i =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/cards",
+ new CreateCardDto(board.Id, col!.Id, $"Concurrent card {i}", null, null, null));
+ statusCodes.Add(resp.StatusCode);
+ if (resp.StatusCode == HttpStatusCode.Created)
+ {
+ var created = await resp.Content.ReadFromJsonAsync();
+ if (created != null) createdIds.Add(created.Id);
+ }
+ }).ToArray();
+
+ barrier.Release(cardCount);
+ await Task.WhenAll(tasks);
+
+ // All should succeed
+ statusCodes.Should().AllSatisfy(s =>
+ s.Should().Be(HttpStatusCode.Created),
+ "all concurrent card creations should succeed");
+
+ // All IDs should be unique
+ createdIds.Distinct().Should().HaveCount(cardCount,
+ "each card should have a unique ID (no duplicates)");
+
+ // Verify via list endpoint
+ var cardsResp = await client.GetAsync($"/api/boards/{board.Id}/cards");
+ var allCards = await cardsResp.Content.ReadFromJsonAsync>();
+ var concurrentCards = allCards!.Where(c =>
+ c.Title.StartsWith("Concurrent card ")).ToList();
+ concurrentCards.Should().HaveCount(cardCount,
+ "all concurrently created cards should appear in the list");
+ }
+}
diff --git a/backend/tests/Taskdeck.Api.Tests/Concurrency/CrossUserIsolationStressTests.cs b/backend/tests/Taskdeck.Api.Tests/Concurrency/CrossUserIsolationStressTests.cs
new file mode 100644
index 00000000..0ede8015
--- /dev/null
+++ b/backend/tests/Taskdeck.Api.Tests/Concurrency/CrossUserIsolationStressTests.cs
@@ -0,0 +1,178 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Http.Json;
+using FluentAssertions;
+using Taskdeck.Api.Tests.Support;
+using Taskdeck.Application.DTOs;
+using Xunit;
+
+namespace Taskdeck.Api.Tests.Concurrency;
+
+///
+/// Cross-user isolation stress tests exercising:
+/// - Concurrent board creation by multiple users
+/// - Verification that no cross-user data leakage occurs
+///
+/// Uses Task.WhenAll with SemaphoreSlim barriers for truly simultaneous execution.
+///
+/// See GitHub issue #705 (TST-55).
+///
+public class CrossUserIsolationStressTests : IClassFixture
+{
+ private readonly TestWebApplicationFactory _factory;
+
+ public CrossUserIsolationStressTests(TestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Concurrent board creation by multiple users.
+ /// Each user creates a board simultaneously. No user should see
+ /// another user's board (cross-user data isolation).
+ ///
+ [Fact]
+ public async Task ConcurrentBoardCreation_NoCrossUserContamination()
+ {
+ const int userCount = 5;
+ var userBoards = new ConcurrentDictionary();
+ var errors = new ConcurrentBag();
+
+ // Phase 1: All users create boards concurrently
+ using var barrier = new SemaphoreSlim(0, userCount);
+ var tasks = Enumerable.Range(0, userCount).Select(async i =>
+ {
+ var client = _factory.CreateClient();
+ var user = await ApiTestHarness.AuthenticateAsync(client, $"isolation-{i}");
+ await barrier.WaitAsync();
+
+ var resp = await client.PostAsJsonAsync(
+ "/api/boards",
+ new CreateBoardDto(
+ $"isolation-board-{i}-{Guid.NewGuid():N}",
+ "stress test board"));
+ if (resp.StatusCode != HttpStatusCode.Created)
+ {
+ errors.Add($"User {i} got {resp.StatusCode}");
+ client.Dispose();
+ return;
+ }
+
+ var board = await resp.Content.ReadFromJsonAsync();
+ userBoards[user.Username] = (board!.Id, client);
+ }).ToArray();
+
+ barrier.Release(userCount);
+ await Task.WhenAll(tasks);
+
+ try
+ {
+ errors.Should().BeEmpty("all users should create boards successfully");
+ userBoards.Should().HaveCount(userCount,
+ "all users should have created their boards successfully");
+
+ // Phase 2: After all boards exist, verify isolation with the
+ // complete set of known board IDs (no race against concurrent inserts)
+ var allBoardIds = userBoards.Values.Select(v => v.BoardId).ToHashSet();
+ foreach (var (username, (boardId, client)) in userBoards)
+ {
+ var listResp = await client.GetAsync("/api/boards");
+ var boards = await listResp.Content.ReadFromJsonAsync>();
+ var visibleOtherBoards = boards!.Where(b =>
+ allBoardIds.Contains(b.Id) && b.Id != boardId).ToList();
+ visibleOtherBoards.Should().BeEmpty(
+ $"user {username} should not see other users' boards");
+ }
+ }
+ finally
+ {
+ foreach (var (_, (_, client)) in userBoards)
+ client.Dispose();
+ }
+ }
+
+ ///
+ /// Concurrent capture item creation by different users on their own boards.
+ /// Each user's capture items should be isolated to their own board.
+ ///
+ [Fact]
+ public async Task ConcurrentCaptureCreation_UserIsolation()
+ {
+ const int userCount = 3;
+ const int itemsPerUser = 3;
+ var errors = new ConcurrentBag();
+
+ // Set up users and boards sequentially (setup phase)
+ var userContexts = new List<(HttpClient Client, TestUserContext User, BoardDto Board)>();
+ try
+ {
+ for (var i = 0; i < userCount; i++)
+ {
+ var client = _factory.CreateClient();
+ var user = await ApiTestHarness.AuthenticateAsync(client, $"cap-iso-{i}");
+ var board = await ApiTestHarness.CreateBoardAsync(client, $"cap-iso-board-{i}");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+
+ userContexts.Add((client, user, board));
+ }
+
+ // All users create capture items concurrently
+ using var barrier = new SemaphoreSlim(0, userCount * itemsPerUser);
+ var allTasks = userContexts.SelectMany(ctx =>
+ Enumerable.Range(0, itemsPerUser).Select(async j =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ ctx.Client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsJsonAsync(
+ "/api/capture/items",
+ new CreateCaptureItemDto(ctx.Board.Id,
+ $"- [ ] User {ctx.User.Username} item {j}"));
+ if (resp.StatusCode != HttpStatusCode.Created)
+ {
+ errors.Add(
+ $"User {ctx.User.Username} item {j} got {resp.StatusCode}");
+ }
+ })).ToArray();
+
+ barrier.Release(userCount * itemsPerUser);
+ await Task.WhenAll(allTasks);
+
+ errors.Should().BeEmpty("all concurrent capture item creations should succeed");
+
+ // Verify each user only sees their own capture items
+ foreach (var ctx in userContexts)
+ {
+ var captureResp = await ctx.Client.GetAsync(
+ $"/api/capture/items?boardId={ctx.Board.Id}");
+ captureResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var items = await captureResp.Content
+ .ReadFromJsonAsync>();
+
+ items.Should().NotBeNull();
+ items!.Should().HaveCount(itemsPerUser,
+ $"user {ctx.User.Username} should see exactly " +
+ $"{itemsPerUser} capture items");
+
+ // Verify none of the items belong to other users
+ foreach (var item in items)
+ {
+ item.BoardId.Should().Be(ctx.Board.Id,
+ $"user {ctx.User.Username} should only see items " +
+ $"from their own board");
+ }
+ }
+ }
+ finally
+ {
+ // Dispose clients even if assertions fail
+ foreach (var ctx in userContexts)
+ ctx.Client.Dispose();
+ }
+ }
+}
diff --git a/backend/tests/Taskdeck.Api.Tests/Concurrency/ProposalApprovalRaceTests.cs b/backend/tests/Taskdeck.Api.Tests/Concurrency/ProposalApprovalRaceTests.cs
new file mode 100644
index 00000000..d8984602
--- /dev/null
+++ b/backend/tests/Taskdeck.Api.Tests/Concurrency/ProposalApprovalRaceTests.cs
@@ -0,0 +1,333 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Http.Json;
+using FluentAssertions;
+using Taskdeck.Api.Tests.Support;
+using Taskdeck.Application.DTOs;
+using Taskdeck.Domain.Entities;
+using Taskdeck.Domain.Enums;
+using Xunit;
+
+namespace Taskdeck.Api.Tests.Concurrency;
+
+///
+/// Proposal approval race condition tests exercising:
+/// 7. Double-approve prevention (two concurrent approve requests)
+/// 8. Approve + Expire race (proposal approved while housekeeping expires it)
+/// 9. Approve + Reject race (concurrent approve and reject)
+/// 10. Double-execute prevention (two concurrent execute requests)
+///
+/// Uses Task.WhenAll with SemaphoreSlim barriers for truly simultaneous execution.
+///
+/// NOTE: SQLite serializes writes, so optimistic concurrency tokens may not
+/// reliably fire under true concurrent access. These tests validate that the
+/// application-layer state machine (PendingReview -> Approved/Rejected/Expired)
+/// produces consistent final states regardless of whether both operations
+/// succeed or one gets 409.
+///
+/// See GitHub issue #705 (TST-55).
+///
+public class ProposalApprovalRaceTests : IClassFixture
+{
+ private readonly TestWebApplicationFactory _factory;
+
+ public ProposalApprovalRaceTests(TestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Helper: creates a capture item, triggers triage, and waits for a proposal
+ /// to be created. Returns the proposal ID.
+ ///
+ private async Task CreateAndWaitForProposalAsync(HttpClient client, Guid boardId, string itemText)
+ {
+ var captureResp = await client.PostAsJsonAsync(
+ "/api/capture/items",
+ new CreateCaptureItemDto(boardId, itemText));
+ captureResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var capture = await captureResp.Content.ReadFromJsonAsync();
+ capture.Should().NotBeNull();
+
+ var triageResp = await client.PostAsync($"/api/capture/items/{capture!.Id}/triage", null);
+ triageResp.StatusCode.Should().Be(HttpStatusCode.Accepted);
+
+ var triaged = await ApiTestHarness.PollUntilAsync(
+ async () =>
+ {
+ var r = await client.GetAsync($"/api/capture/items/{capture.Id}");
+ return await r.Content.ReadFromJsonAsync();
+ },
+ item => item?.Status == CaptureStatus.ProposalCreated,
+ "proposal creation",
+ maxAttempts: 80);
+
+ return triaged.Provenance!.ProposalId!.Value;
+ }
+
+ ///
+ /// Scenario 7: Double-approve prevention.
+ /// Two concurrent approve requests for the same proposal.
+ /// At least one should succeed; any failing request should get 409 Conflict.
+ /// The proposal should end in Approved state.
+ ///
+ [Fact]
+ public async Task DoubleApprove_ExactlyOneSucceeds()
+ {
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "proposal-double-approve");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "double-approve-board");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+
+ var proposalId = await CreateAndWaitForProposalAsync(
+ client, board.Id, "- [ ] Double approve item");
+
+ // Two concurrent approve requests
+ using var barrier = new SemaphoreSlim(0, 2);
+ var statusCodes = new ConcurrentBag();
+
+ var approveTasks = Enumerable.Range(0, 2).Select(async _ =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsync(
+ $"/api/automation/proposals/{proposalId}/approve", null);
+ statusCodes.Add(resp.StatusCode);
+ }).ToArray();
+
+ barrier.Release(2);
+ await Task.WhenAll(approveTasks);
+
+ var codes = statusCodes.ToList();
+ var successCount = codes.Count(s => s == HttpStatusCode.OK);
+
+ successCount.Should().BeGreaterThanOrEqualTo(1,
+ "at least one concurrent approve should succeed");
+ codes.Where(s => s != HttpStatusCode.OK)
+ .Should().OnlyContain(s => s == HttpStatusCode.Conflict,
+ "any failing concurrent approve should return 409 Conflict");
+
+ // Verify final state
+ var proposalResp = await client.GetAsync($"/api/automation/proposals/{proposalId}");
+ proposalResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var proposal = await proposalResp.Content.ReadFromJsonAsync();
+ proposal!.Status.Should().Be(ProposalStatus.Approved,
+ "proposal should be in Approved state after double-approve race");
+ }
+
+ ///
+ /// Scenario 8: Approve + Expire race.
+ /// One client approves a proposal while another simulates housekeeping
+ /// expiry by rejecting it with a reason (since we cannot directly invoke
+ /// the housekeeping worker's expire logic via HTTP). The key invariant:
+ /// the proposal ends in a decided state (Approved, Rejected, or Expired).
+ ///
+ /// Note: The actual Expire() call is internal to the housekeeping worker
+ /// and operates on the domain entity directly. This test validates the
+ /// HTTP-level race between approve and reject as a proxy for approve+expire.
+ /// For the domain-level approve+expire race, see
+ /// ProposalHousekeepingWorkerEdgeCaseTests.
+ ///
+ [Fact]
+ public async Task ApproveAndExpireRace_OneWinsCleanly()
+ {
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "proposal-approve-expire");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "approve-expire-board");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+
+ var proposalId = await CreateAndWaitForProposalAsync(
+ client, board.Id, "- [ ] Approve vs expire item");
+
+ // One client approves, another rejects (simulating expire via HTTP)
+ using var barrier = new SemaphoreSlim(0, 2);
+ var results = new ConcurrentDictionary();
+
+ var approveTask = Task.Run(async () =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsync(
+ $"/api/automation/proposals/{proposalId}/approve", null);
+ results["approve"] = resp.StatusCode;
+ });
+
+ var rejectTask = Task.Run(async () =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsJsonAsync(
+ $"/api/automation/proposals/{proposalId}/reject",
+ new UpdateProposalStatusDto("expired by housekeeping (simulated)"));
+ results["reject"] = resp.StatusCode;
+ });
+
+ barrier.Release(2);
+ await Task.WhenAll(approveTask, rejectTask);
+
+ // At least one should succeed
+ var successCount = (results["approve"] == HttpStatusCode.OK ? 1 : 0)
+ + (results["reject"] == HttpStatusCode.OK ? 1 : 0);
+ successCount.Should().BeGreaterThanOrEqualTo(1,
+ "at least one of approve/expire(reject) should succeed");
+ results.Values.Should().OnlyContain(
+ s => s == HttpStatusCode.OK || s == HttpStatusCode.Conflict,
+ "losing operation should get 409 Conflict");
+
+ // Verify final state is consistent
+ var proposalResp = await client.GetAsync($"/api/automation/proposals/{proposalId}");
+ proposalResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var proposal = await proposalResp.Content.ReadFromJsonAsync();
+ proposal!.Status.Should().BeOneOf(
+ new[] { ProposalStatus.Approved, ProposalStatus.Rejected },
+ "proposal should be in a decided state after approve+expire race");
+ }
+
+ ///
+ /// Scenario 9: Approve + Reject race.
+ /// One client approves, another rejects the same proposal concurrently.
+ /// One should win; the proposal should end in either Approved or Rejected.
+ ///
+ [Fact]
+ public async Task ApproveAndReject_OneWinsCleanly()
+ {
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "proposal-approve-reject");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "approve-reject-board");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+
+ var proposalId = await CreateAndWaitForProposalAsync(
+ client, board.Id, "- [ ] Approve vs reject item");
+
+ // One client approves, another rejects simultaneously
+ using var barrier = new SemaphoreSlim(0, 2);
+ var results = new ConcurrentDictionary();
+
+ var approveTask = Task.Run(async () =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsync(
+ $"/api/automation/proposals/{proposalId}/approve", null);
+ results["approve"] = resp.StatusCode;
+ });
+
+ var rejectTask = Task.Run(async () =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsJsonAsync(
+ $"/api/automation/proposals/{proposalId}/reject",
+ new UpdateProposalStatusDto("rejected in race test"));
+ results["reject"] = resp.StatusCode;
+ });
+
+ barrier.Release(2);
+ await Task.WhenAll(approveTask, rejectTask);
+
+ var successCount = (results["approve"] == HttpStatusCode.OK ? 1 : 0)
+ + (results["reject"] == HttpStatusCode.OK ? 1 : 0);
+ successCount.Should().BeGreaterThanOrEqualTo(1,
+ "at least one of approve/reject should succeed");
+ results.Values.Should().OnlyContain(
+ s => s == HttpStatusCode.OK || s == HttpStatusCode.Conflict,
+ "losing operation should get 409 Conflict");
+
+ // Verify final state
+ var proposalResp = await client.GetAsync($"/api/automation/proposals/{proposalId}");
+ proposalResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var proposal = await proposalResp.Content.ReadFromJsonAsync();
+ proposal!.Status.Should().BeOneOf(
+ new[] { ProposalStatus.Approved, ProposalStatus.Rejected },
+ "proposal should be in a decided state after concurrent decisions");
+ }
+
+ ///
+ /// Scenario 10: Double-execute prevention.
+ /// Approve a proposal, then send two execute requests concurrently.
+ /// The proposal should end in Applied state with no duplicate side effects.
+ ///
+ [Fact]
+ public async Task DoubleExecute_NoDuplicateSideEffects()
+ {
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "proposal-double-exec");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "double-exec-board");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+
+ var proposalId = await CreateAndWaitForProposalAsync(
+ client, board.Id, "- [ ] Double execute item");
+
+ // Approve the proposal first
+ var approveResp = await client.PostAsync(
+ $"/api/automation/proposals/{proposalId}/approve", null);
+ approveResp.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ // Two concurrent execute requests
+ using var barrier = new SemaphoreSlim(0, 2);
+ var statusCodes = new ConcurrentBag();
+
+ var executeTasks = Enumerable.Range(0, 2).Select(async i =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ var request = new HttpRequestMessage(HttpMethod.Post,
+ $"/api/automation/proposals/{proposalId}/execute");
+ request.Headers.Add("Idempotency-Key", $"exec-race-{i}-{Guid.NewGuid()}");
+ await barrier.WaitAsync();
+ var resp = await raceClient.SendAsync(request);
+ statusCodes.Add(resp.StatusCode);
+ }).ToArray();
+
+ barrier.Release(2);
+ await Task.WhenAll(executeTasks);
+
+ var codes = statusCodes.ToList();
+ var okCount = codes.Count(s => s == HttpStatusCode.OK);
+ okCount.Should().BeGreaterOrEqualTo(1,
+ "at least one execute should succeed");
+ // NOTE: SQLite serializes writes, so both may succeed sequentially.
+
+ // Verify the proposal ended in Applied state
+ var proposalResp = await client.GetAsync($"/api/automation/proposals/{proposalId}");
+ proposalResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var proposal = await proposalResp.Content.ReadFromJsonAsync();
+ proposal!.Status.Should().Be(ProposalStatus.Applied,
+ "proposal should be in Applied state after execution");
+
+ // Verify exactly one card was created (not duplicated, not lost)
+ var cardsResp = await client.GetAsync($"/api/boards/{board.Id}/cards");
+ cardsResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var cards = await cardsResp.Content.ReadFromJsonAsync>();
+ var matchingCards = cards!.Count(c => c.Title.Contains("Double execute item"));
+ matchingCards.Should().Be(1,
+ "double execute should create exactly one card (no duplicates, no data loss)");
+ }
+}
diff --git a/backend/tests/Taskdeck.Api.Tests/Concurrency/QueueClaimRaceTests.cs b/backend/tests/Taskdeck.Api.Tests/Concurrency/QueueClaimRaceTests.cs
new file mode 100644
index 00000000..cdbbab48
--- /dev/null
+++ b/backend/tests/Taskdeck.Api.Tests/Concurrency/QueueClaimRaceTests.cs
@@ -0,0 +1,298 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Http.Json;
+using FluentAssertions;
+using Taskdeck.Api.Tests.Support;
+using Taskdeck.Application.DTOs;
+using Taskdeck.Domain.Enums;
+using Xunit;
+
+namespace Taskdeck.Api.Tests.Concurrency;
+
+///
+/// Queue claim race condition tests exercising:
+/// 1. Double-claim prevention with 10 parallel workers on same LLM queue item
+/// 2. Capture triage claim with stale expectedUpdatedAt
+/// 3. Batch processing with concurrent workers (no item processed twice)
+///
+/// Uses Task.WhenAll with SemaphoreSlim barriers for truly simultaneous execution.
+///
+/// NOTE: SQLite uses file-level write locking, which serializes concurrent writes
+/// at the database level. These tests validate application-layer claim guards
+/// (optimistic concurrency via UpdatedAt, status checks) regardless of whether
+/// SQLite serializes the underlying writes. In production with PostgreSQL, these
+/// guards would prevent true concurrent claim races at the row level.
+///
+/// See GitHub issue #705 (TST-55).
+///
+public class QueueClaimRaceTests : IClassFixture
+{
+ private readonly TestWebApplicationFactory _factory;
+
+ public QueueClaimRaceTests(TestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Scenario 1: 10 parallel workers all try to process the next pending LLM queue item.
+ /// Under SQLite's file-level write serialization, multiple workers may read the same
+ /// pending item before any status update commits, causing more than one to succeed.
+ /// This is a known SQLite limitation -- with PostgreSQL row-level locking, at most
+ /// one worker would claim each item.
+ ///
+ /// The test validates:
+ /// - No 500 errors under concurrent access
+ /// - At least one worker succeeds
+ /// - No deadlocks or hangs (test completes within timeout)
+ ///
+ [Fact]
+ public async Task ProcessNext_TenParallelWorkers_NoErrorsUnderConcurrentAccess()
+ {
+ const int workerCount = 10;
+ using var setupClient = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(setupClient, "queue-claim-10");
+
+ // Seed a single LLM queue item
+ var queueResp = await setupClient.PostAsJsonAsync(
+ "/api/llm-queue",
+ new CreateLlmRequestDto("summarize", "payload for claim race"));
+ queueResp.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ // Fire 10 parallel process-next requests
+ using var barrier = new SemaphoreSlim(0, workerCount);
+ var results = new ConcurrentBag();
+
+ var tasks = Enumerable.Range(0, workerCount).Select(async _ =>
+ {
+ using var workerClient = _factory.CreateClient();
+ workerClient.DefaultRequestHeaders.Authorization =
+ setupClient.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await workerClient.PostAsync("/api/llm-queue/process-next", null);
+ results.Add(resp.StatusCode);
+ }).ToArray();
+
+ barrier.Release(workerCount);
+ await Task.WhenAll(tasks);
+
+ var codes = results.ToList();
+ var successCount = codes.Count(s => s == HttpStatusCode.OK);
+
+ // At least one worker should succeed
+ successCount.Should().BeGreaterOrEqualTo(1,
+ "at least one worker should process the pending item");
+
+ // NOTE: Under SQLite, multiple workers may succeed because reads are not
+ // serialized against writes at the row level. With PostgreSQL, we would
+ // expect successCount <= 1 due to SELECT ... FOR UPDATE or advisory locks.
+ // The important invariant is no 500 errors and no deadlocks.
+
+ // All responses should be well-formed (no 500s)
+ codes.Should().NotContain(HttpStatusCode.InternalServerError,
+ "no internal server errors should occur during concurrent claim attempts");
+
+ // Remaining workers should get 404 (no pending item) or OK
+ codes.Should().OnlyContain(
+ s => s == HttpStatusCode.OK || s == HttpStatusCode.NotFound
+ || s == HttpStatusCode.BadRequest,
+ "workers should only get OK, 404, or 400 -- not unexpected errors");
+ }
+
+ ///
+ /// Scenario 2: Capture triage with stale timestamp.
+ /// After a capture item has been triaged once, a second triage attempt
+ /// (simulating a stale read) should not produce a duplicate proposal.
+ ///
+ [Fact]
+ public async Task CaptureTriage_StaleTimestamp_NoDuplicateProposal()
+ {
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "queue-stale-claim");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "queue-stale-board");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+
+ var captureResp = await client.PostAsJsonAsync(
+ "/api/capture/items",
+ new CreateCaptureItemDto(board.Id, "- [ ] Stale claim item"));
+ captureResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var capture = await captureResp.Content.ReadFromJsonAsync();
+ capture.Should().NotBeNull();
+
+ // First triage
+ var firstTriage = await client.PostAsync($"/api/capture/items/{capture!.Id}/triage", null);
+ firstTriage.StatusCode.Should().Be(HttpStatusCode.Accepted);
+
+ // Wait for processing to advance
+ await ApiTestHarness.PollUntilAsync(
+ async () =>
+ {
+ var r = await client.GetAsync($"/api/capture/items/{capture.Id}");
+ return await r.Content.ReadFromJsonAsync();
+ },
+ item => item?.Status is CaptureStatus.ProposalCreated or CaptureStatus.Triaging,
+ "capture triage processing",
+ maxAttempts: 30);
+
+ // Second triage attempt on already-processed item
+ var secondTriage = await client.PostAsync($"/api/capture/items/{capture.Id}/triage", null);
+
+ // Should either reject or be idempotent
+ secondTriage.StatusCode.Should().BeOneOf(
+ HttpStatusCode.Accepted,
+ HttpStatusCode.OK,
+ HttpStatusCode.Conflict,
+ HttpStatusCode.BadRequest,
+ (HttpStatusCode)429);
+
+ // Count proposals to ensure no duplicates
+ var proposalsResp = await client.GetAsync($"/api/automation/proposals?boardId={board.Id}");
+ proposalsResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var proposals = await proposalsResp.Content.ReadFromJsonAsync>();
+ proposals.Should().NotBeNull();
+
+ var captureProposals = proposals!.Where(p =>
+ p.SourceReferenceId == capture.Id.ToString()).ToList();
+ captureProposals.Should().HaveCountLessOrEqualTo(1,
+ "stale re-triage should not create duplicate proposals");
+ }
+
+ ///
+ /// Scenario 3: Batch processing with concurrent workers on different items.
+ /// Multiple capture items are triaged simultaneously by different workers.
+ /// No item should be processed twice.
+ ///
+ [Fact]
+ public async Task CaptureTriage_BatchConcurrentWorkers_NoItemProcessedTwice()
+ {
+ const int batchSize = 5;
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "queue-batch-workers");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "queue-batch-board");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+
+ // Create multiple capture items
+ var captureIds = new List();
+ for (var i = 0; i < batchSize; i++)
+ {
+ var resp = await client.PostAsJsonAsync(
+ "/api/capture/items",
+ new CreateCaptureItemDto(board.Id, $"- [ ] Batch item {i}"));
+ resp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var item = await resp.Content.ReadFromJsonAsync();
+ captureIds.Add(item!.Id);
+ }
+
+ // Triage all items concurrently
+ using var barrier = new SemaphoreSlim(0, batchSize);
+ var results = new ConcurrentDictionary();
+
+ var tasks = captureIds.Select(async captureId =>
+ {
+ using var workerClient = _factory.CreateClient();
+ workerClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await workerClient.PostAsync(
+ $"/api/capture/items/{captureId}/triage", null);
+ results[captureId] = resp.StatusCode;
+ }).ToArray();
+
+ barrier.Release(batchSize);
+ await Task.WhenAll(tasks);
+
+ // Each distinct item should triage without conflict
+ results.Values.Should().AllSatisfy(s =>
+ s.Should().BeOneOf(HttpStatusCode.Accepted, HttpStatusCode.OK),
+ "each distinct capture item should triage without conflict");
+
+ // Poll for proposals to settle, then verify no duplicates.
+ // Use PollUntilAsync-style polling instead of Task.Delay to avoid flakiness.
+ var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(15);
+ List? proposals = null;
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ var proposalsResp = await client.GetAsync($"/api/automation/proposals?boardId={board.Id}");
+ proposalsResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ proposals = await proposalsResp.Content.ReadFromJsonAsync>();
+ if (proposals != null && proposals.Count >= captureIds.Count)
+ break;
+ await Task.Delay(200);
+ }
+
+ proposals.Should().NotBeNull();
+
+ // Each capture item should have exactly one proposal (no duplicates, no data loss)
+ foreach (var captureId in captureIds)
+ {
+ var matching = proposals!.Count(p => p.SourceReferenceId == captureId.ToString());
+ matching.Should().Be(1,
+ $"capture item {captureId} should have exactly one proposal (no duplicate processing, no data loss)");
+ }
+ }
+
+ ///
+ /// Scenario: Two workers both call process-next simultaneously for different
+ /// pending items. Each should claim a different item (no double processing).
+ ///
+ [Fact]
+ public async Task ProcessNext_TwoWorkersTwoItems_EachClaimsDifferentItem()
+ {
+ using var setupClient = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(setupClient, "queue-two-workers");
+
+ // Seed two LLM queue items
+ var q1 = await setupClient.PostAsJsonAsync(
+ "/api/llm-queue",
+ new CreateLlmRequestDto("summarize", "payload-A"));
+ q1.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var q2 = await setupClient.PostAsJsonAsync(
+ "/api/llm-queue",
+ new CreateLlmRequestDto("summarize", "payload-B"));
+ q2.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ // Two workers process-next simultaneously
+ using var barrier = new SemaphoreSlim(0, 2);
+ var responseData = new ConcurrentBag<(HttpStatusCode Status, string? Body)>();
+
+ var workerTasks = Enumerable.Range(0, 2).Select(async _ =>
+ {
+ using var workerClient = _factory.CreateClient();
+ workerClient.DefaultRequestHeaders.Authorization =
+ setupClient.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await workerClient.PostAsync("/api/llm-queue/process-next", null);
+ var body = await resp.Content.ReadAsStringAsync();
+ responseData.Add((resp.StatusCode, body));
+ }).ToArray();
+
+ barrier.Release(2);
+ await Task.WhenAll(workerTasks);
+
+ var responses = responseData.ToList();
+
+ // No 500 errors
+ responses.Should().NotContain(r => r.Status == HttpStatusCode.InternalServerError,
+ "no internal server errors during concurrent processing");
+
+ // At least one worker should succeed (with 2 items, ideally both succeed)
+ var successResponses = responses.Where(r => r.Status == HttpStatusCode.OK).ToList();
+ successResponses.Should().NotBeEmpty(
+ "at least one worker should successfully claim an item");
+
+ // All responses should be well-formed (OK or 404, not unexpected errors)
+ responses.Should().OnlyContain(
+ r => r.Status == HttpStatusCode.OK || r.Status == HttpStatusCode.NotFound
+ || r.Status == HttpStatusCode.BadRequest,
+ "workers should only get OK, 404, or 400 -- not unexpected errors");
+ }
+}
diff --git a/backend/tests/Taskdeck.Api.Tests/Concurrency/RateLimitingConcurrencyTests.cs b/backend/tests/Taskdeck.Api.Tests/Concurrency/RateLimitingConcurrencyTests.cs
new file mode 100644
index 00000000..5ad30d1c
--- /dev/null
+++ b/backend/tests/Taskdeck.Api.Tests/Concurrency/RateLimitingConcurrencyTests.cs
@@ -0,0 +1,156 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Net;
+using System.Net.Http.Json;
+using FluentAssertions;
+using Microsoft.AspNetCore.Hosting;
+using Taskdeck.Api.RateLimiting;
+using Taskdeck.Api.Tests.Support;
+using Taskdeck.Application.DTOs;
+using Xunit;
+
+namespace Taskdeck.Api.Tests.Concurrency;
+
+///
+/// Rate limiting concurrency tests exercising:
+/// 12. Burst beyond limit (correct number throttled)
+/// 13. Cross-user isolation under load (user A hitting limit doesn't affect user B)
+///
+/// Uses Task.WhenAll with SemaphoreSlim barriers for burst execution.
+///
+/// See GitHub issue #705 (TST-55).
+///
+public class RateLimitingConcurrencyTests : IClassFixture
+{
+ private readonly TestWebApplicationFactory _factory;
+
+ public RateLimitingConcurrencyTests(TestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Scenario 12: Burst of requests beyond the rate limit.
+ /// Fires N requests simultaneously; after the permit limit is hit,
+ /// additional requests should receive 429 Too Many Requests.
+ ///
+ [Fact]
+ public async Task BurstBeyondLimit_ExcessRequestsGet429()
+ {
+ const int permitLimit = 2;
+ const int burstSize = 5;
+
+ using var factory = _factory.WithWebHostBuilder(builder =>
+ {
+ builder.UseSetting("RateLimiting:Enabled", "true");
+ builder.UseSetting("RateLimiting:AuthPerIp:PermitLimit",
+ permitLimit.ToString(CultureInfo.InvariantCulture));
+ builder.UseSetting("RateLimiting:AuthPerIp:WindowSeconds", "60");
+ });
+
+ using var barrier = new SemaphoreSlim(0, burstSize);
+ var statusCodes = new ConcurrentBag();
+
+ var burstTasks = Enumerable.Range(0, burstSize).Select(async _ =>
+ {
+ using var client = factory.CreateClient();
+ await barrier.WaitAsync();
+ var resp = await client.PostAsJsonAsync(
+ "/api/auth/login",
+ new LoginDto($"burst-user-{Guid.NewGuid():N}", "wrong-pass"));
+ statusCodes.Add(resp.StatusCode);
+ }).ToArray();
+
+ barrier.Release(burstSize);
+ await Task.WhenAll(burstTasks);
+
+ var codes = statusCodes.ToList();
+ var throttledCount = codes.Count(s => s == (HttpStatusCode)429);
+ throttledCount.Should().BeGreaterOrEqualTo(burstSize - permitLimit,
+ $"with permit limit {permitLimit} and burst {burstSize}, " +
+ $"at least {burstSize - permitLimit} requests should be throttled");
+ }
+
+ ///
+ /// Scenario 13: Cross-user isolation under load.
+ /// Two users send requests; each user's rate limit should be tracked
+ /// independently. User A being throttled should not throttle user B.
+ ///
+ [Fact]
+ public async Task CrossUserIsolation_UsersThrottledIndependently()
+ {
+ const int permitLimit = 1;
+
+ using var factory = _factory.WithWebHostBuilder(builder =>
+ {
+ builder.UseSetting("RateLimiting:Enabled", "true");
+ builder.UseSetting("RateLimiting:AuthPerIp:PermitLimit", "200");
+ builder.UseSetting("RateLimiting:AuthPerIp:WindowSeconds", "60");
+ builder.UseSetting("RateLimiting:HotPathPerUser:PermitLimit",
+ permitLimit.ToString(CultureInfo.InvariantCulture));
+ builder.UseSetting("RateLimiting:HotPathPerUser:WindowSeconds", "60");
+ });
+
+ // Register two independent users
+ using var clientA = factory.CreateClient();
+ using var clientB = factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(clientA, "rate-iso-a");
+ await ApiTestHarness.AuthenticateAsync(clientB, "rate-iso-b");
+
+ // User A fires 2 requests (first OK, second should be 429)
+ var a1 = await clientA.PostAsJsonAsync(
+ "/api/llm-queue",
+ new CreateLlmRequestDto("summarize", "payload A-1"));
+ a1.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var a2 = await clientA.PostAsJsonAsync(
+ "/api/llm-queue",
+ new CreateLlmRequestDto("summarize", "payload A-2"));
+ a2.StatusCode.Should().Be((HttpStatusCode)429,
+ "user A should be throttled after exceeding per-user limit");
+
+ // User B's first request should still succeed
+ var b1 = await clientB.PostAsJsonAsync(
+ "/api/llm-queue",
+ new CreateLlmRequestDto("summarize", "payload B-1"));
+ b1.StatusCode.Should().Be(HttpStatusCode.OK,
+ "user B should not be affected by user A's throttling");
+ }
+
+ ///
+ /// Scenario 12b: Verify that throttled requests include Retry-After header.
+ ///
+ [Fact]
+ public async Task ThrottledRequests_IncludeRetryAfterHeader()
+ {
+ const int permitLimit = 1;
+
+ using var factory = _factory.WithWebHostBuilder(builder =>
+ {
+ builder.UseSetting("RateLimiting:Enabled", "true");
+ builder.UseSetting("RateLimiting:AuthPerIp:PermitLimit",
+ permitLimit.ToString(CultureInfo.InvariantCulture));
+ builder.UseSetting("RateLimiting:AuthPerIp:WindowSeconds", "60");
+ });
+
+ using var client = factory.CreateClient();
+
+ // First request should succeed
+ var first = await client.PostAsJsonAsync(
+ "/api/auth/login",
+ new LoginDto("retry-header-user", "wrong-pass"));
+
+ // Second request should be throttled
+ var second = await client.PostAsJsonAsync(
+ "/api/auth/login",
+ new LoginDto("retry-header-user-2", "wrong-pass"));
+
+ // Assert the rate limiter actually kicks in before checking headers
+ second.StatusCode.Should().Be((HttpStatusCode)429,
+ "the second request should be throttled (permit limit is 1)");
+
+ // Retry-After header should be present on 429 responses
+ second.Headers.Contains("Retry-After").Should().BeTrue(
+ "429 responses should include a Retry-After header");
+ }
+}
diff --git a/backend/tests/Taskdeck.Api.Tests/Concurrency/WebhookDeliveryConcurrencyTests.cs b/backend/tests/Taskdeck.Api.Tests/Concurrency/WebhookDeliveryConcurrencyTests.cs
new file mode 100644
index 00000000..14d4f66a
--- /dev/null
+++ b/backend/tests/Taskdeck.Api.Tests/Concurrency/WebhookDeliveryConcurrencyTests.cs
@@ -0,0 +1,196 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Http.Json;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Taskdeck.Api.Tests.Support;
+using Taskdeck.Application.DTOs;
+using Taskdeck.Application.Interfaces;
+using Taskdeck.Domain.Entities;
+using Xunit;
+
+namespace Taskdeck.Api.Tests.Concurrency;
+
+///
+/// Webhook delivery concurrency tests exercising:
+/// 11. Concurrent board mutations → each gets own delivery record
+/// 12. Concurrent webhook subscription creation → all succeed with distinct IDs
+///
+/// Uses Task.WhenAll with Barrier for truly simultaneous execution.
+///
+/// NOTE: Webhook delivery records are created asynchronously after the HTTP
+/// response returns. Tests poll with a timeout to verify delivery records
+/// are eventually created.
+///
+/// See GitHub issue #705 (TST-55).
+///
+public class WebhookDeliveryConcurrencyTests : IClassFixture
+{
+ private readonly TestWebApplicationFactory _factory;
+
+ public WebhookDeliveryConcurrencyTests(TestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Scenario 11: Concurrent board mutations should each create webhook deliveries.
+ /// Multiple card operations fire concurrently on a board with an active
+ /// webhook subscription. Each mutation should produce its own delivery
+ /// record without duplicates or lost events.
+ ///
+ [Fact]
+ public async Task ConcurrentBoardMutations_EachCreatesDeliveryRecord()
+ {
+ const int mutationCount = 5;
+
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "webhook-concurrent-delivery");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "webhook-delivery-board");
+
+ var colResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/columns",
+ new CreateColumnDto(board.Id, "Backlog", null, null));
+ colResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var col = await colResp.Content.ReadFromJsonAsync();
+
+ // Create a webhook subscription
+ var webhookResp = await client.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/webhooks",
+ new CreateOutboundWebhookSubscriptionDto(
+ "https://example.com/webhook-delivery-test",
+ new List { "card.*" }));
+ webhookResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ var webhookSub = await webhookResp.Content
+ .ReadFromJsonAsync();
+ webhookSub.Should().NotBeNull();
+
+ // Create multiple cards concurrently using SemaphoreSlim (async-safe,
+ // unlike Barrier.SignalAndWait which blocks thread-pool threads)
+ using var barrier = new SemaphoreSlim(0, mutationCount);
+ var statusCodes = new ConcurrentBag();
+
+ var mutationTasks = Enumerable.Range(0, mutationCount).Select(async i =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/cards",
+ new CreateCardDto(board.Id, col!.Id, $"Webhook card {i}", null, null, null));
+ statusCodes.Add(resp.StatusCode);
+ }).ToArray();
+
+ barrier.Release(mutationCount);
+ await Task.WhenAll(mutationTasks);
+
+ // All card creations should succeed
+ statusCodes.Should().AllSatisfy(s =>
+ s.Should().Be(HttpStatusCode.Created),
+ "all concurrent card creations should succeed");
+
+ // Verify all cards were created (no duplicates, no losses)
+ var cardsResp = await client.GetAsync($"/api/boards/{board.Id}/cards");
+ cardsResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var cards = await cardsResp.Content.ReadFromJsonAsync>();
+ var webhookCards = cards!.Where(c => c.Title.StartsWith("Webhook card ")).ToList();
+ webhookCards.Should().HaveCount(mutationCount,
+ "each concurrent mutation should create exactly one card");
+ webhookCards.Select(c => c.Title).Distinct().Should().HaveCount(mutationCount,
+ "each card title should be unique (no duplicate processing)");
+
+ // Poll for webhook delivery records (created asynchronously)
+ using var scope = _factory.Services.CreateScope();
+ var deliveryRepo = scope.ServiceProvider
+ .GetRequiredService();
+
+ var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10);
+ IReadOnlyList deliveries = [];
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ deliveries = await deliveryRepo.GetBySubscriptionAsync(
+ webhookSub!.Subscription.Id, limit: mutationCount + 5);
+ if (deliveries.Count >= mutationCount)
+ break;
+ await Task.Delay(100);
+ }
+
+ deliveries.Should().HaveCount(mutationCount,
+ $"each of the {mutationCount} card mutations should create exactly one webhook delivery record");
+ deliveries.Select(d => d.Id).Distinct().Should().HaveCount(deliveries.Count,
+ "each delivery record should have a unique ID");
+ }
+
+ ///
+ /// Scenario 12: Concurrent webhook subscription creation on the same board.
+ /// Multiple subscriptions created simultaneously should all succeed with
+ /// distinct IDs and signing secrets.
+ ///
+ [Fact]
+ public async Task ConcurrentSubscriptionCreation_AllSucceedWithDistinctIds()
+ {
+ const int subscriptionCount = 3;
+
+ using var client = _factory.CreateClient();
+ await ApiTestHarness.AuthenticateAsync(client, "webhook-concurrent-sub");
+ var board = await ApiTestHarness.CreateBoardAsync(client, "webhook-sub-board");
+
+ using var barrier = new SemaphoreSlim(0, subscriptionCount);
+ var results = new ConcurrentBag<(HttpStatusCode Status, OutboundWebhookSubscriptionSecretDto? Sub)>();
+
+ var tasks = Enumerable.Range(0, subscriptionCount).Select(async i =>
+ {
+ using var raceClient = _factory.CreateClient();
+ raceClient.DefaultRequestHeaders.Authorization =
+ client.DefaultRequestHeaders.Authorization;
+ await barrier.WaitAsync();
+ var resp = await raceClient.PostAsJsonAsync(
+ $"/api/boards/{board.Id}/webhooks",
+ new CreateOutboundWebhookSubscriptionDto(
+ $"https://example.com/webhook-{i}",
+ new List { "card.*" }));
+ var sub = resp.StatusCode == HttpStatusCode.Created
+ ? await resp.Content.ReadFromJsonAsync()
+ : null;
+ results.Add((resp.StatusCode, sub));
+ }).ToArray();
+
+ barrier.Release(subscriptionCount);
+ await Task.WhenAll(tasks);
+
+ // All should succeed
+ results.Select(r => r.Status).Should().AllSatisfy(s =>
+ s.Should().Be(HttpStatusCode.Created),
+ "all concurrent webhook subscription creations should succeed");
+
+ // IDs should be distinct
+ var ids = results.Where(r => r.Sub != null)
+ .Select(r => r.Sub!.Subscription.Id).ToList();
+ ids.Distinct().Should().HaveCount(subscriptionCount,
+ "each subscription should have a unique ID");
+
+ // Signing secrets should be distinct
+ var secrets = results.Where(r => r.Sub != null)
+ .Select(r => r.Sub!.SigningSecret).ToList();
+ secrets.Distinct().Should().HaveCount(subscriptionCount,
+ "each subscription should have a unique signing secret");
+
+ // Verify via list endpoint
+ var listResp = await client.GetAsync($"/api/boards/{board.Id}/webhooks");
+ listResp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var listedSubs = await listResp.Content
+ .ReadFromJsonAsync>();
+ listedSubs.Should().NotBeNull();
+ listedSubs!.Should().HaveCountGreaterThanOrEqualTo(subscriptionCount);
+ listedSubs.Select(s => s.Id).Distinct()
+ .Should().HaveCountGreaterThanOrEqualTo(subscriptionCount);
+
+ // Cross-check: all IDs from creation should appear in the list
+ foreach (var createdId in ids)
+ {
+ listedSubs.Should().Contain(s => s.Id == createdId,
+ $"subscription {createdId} should appear in list endpoint");
+ }
+ }
+}