diff --git a/backend/src/Taskdeck.Api/Hubs/BoardsHub.cs b/backend/src/Taskdeck.Api/Hubs/BoardsHub.cs index 205dea7b41..5baefdfb6b 100644 --- a/backend/src/Taskdeck.Api/Hubs/BoardsHub.cs +++ b/backend/src/Taskdeck.Api/Hubs/BoardsHub.cs @@ -33,6 +33,17 @@ public async Task JoinBoard(Guid boardId) if (!permission.Value) throw new HubException($"{ErrorCodes.Forbidden}:You do not have access to this board"); + // Safety net: the tracker holds one board per connection, but a JoinBoard that + // arrives without a matching LeaveBoard would otherwise leave the connection in + // the old SignalR group (ghost presence plus stray old-board mutations). + if (_presenceTracker.TryGetBoard(Context.ConnectionId, out var previousBoardId) + && previousBoardId != boardId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, BoardHubGroups.ForBoard(previousBoardId)); + var previous = _presenceTracker.Leave(previousBoardId, Context.ConnectionId); + await PublishPresenceSnapshotAsync(previous); + } + await Groups.AddToGroupAsync(Context.ConnectionId, BoardHubGroups.ForBoard(boardId)); var presence = _presenceTracker.Join(boardId, Context.ConnectionId, userId, displayName); await PublishPresenceSnapshotAsync(presence); diff --git a/backend/src/Taskdeck.Api/Realtime/IBoardPresenceTracker.cs b/backend/src/Taskdeck.Api/Realtime/IBoardPresenceTracker.cs index 19cf2a0c0d..4c6eff637e 100644 --- a/backend/src/Taskdeck.Api/Realtime/IBoardPresenceTracker.cs +++ b/backend/src/Taskdeck.Api/Realtime/IBoardPresenceTracker.cs @@ -15,4 +15,7 @@ BoardPresenceSnapshot Join( BoardPresenceSnapshot? UpdateEditingCard(Guid boardId, string connectionId, Guid? editingCardId); bool IsConnectionJoinedBoard(string connectionId, Guid boardId); + + /// The board a connection currently observes, if any. + bool TryGetBoard(string connectionId, out Guid boardId); } diff --git a/backend/src/Taskdeck.Api/Realtime/InMemoryBoardPresenceTracker.cs b/backend/src/Taskdeck.Api/Realtime/InMemoryBoardPresenceTracker.cs index 275187a005..ab135548ab 100644 --- a/backend/src/Taskdeck.Api/Realtime/InMemoryBoardPresenceTracker.cs +++ b/backend/src/Taskdeck.Api/Realtime/InMemoryBoardPresenceTracker.cs @@ -100,6 +100,14 @@ public bool IsConnectionJoinedBoard(string connectionId, Guid boardId) } } + public bool TryGetBoard(string connectionId, out Guid boardId) + { + lock (_gate) + { + return _boardByConnection.TryGetValue(connectionId, out boardId); + } + } + private static BoardPresenceSnapshot CreateSnapshot( Guid boardId, Dictionary boardConnections) diff --git a/backend/tests/Taskdeck.Api.Tests/BoardsHubSwitchTests.cs b/backend/tests/Taskdeck.Api.Tests/BoardsHubSwitchTests.cs new file mode 100644 index 0000000000..476f7394cb --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/BoardsHubSwitchTests.cs @@ -0,0 +1,156 @@ +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.SignalR; +using Moq; +using Taskdeck.Api.Hubs; +using Taskdeck.Api.Realtime; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Xunit; + +namespace Taskdeck.Api.Tests; + +/// +/// Hub-level cover for the JoinBoard switch safety net: a JoinBoard that arrives +/// without a matching LeaveBoard must not strand the connection in the old +/// SignalR group with ghost presence. +/// +public class BoardsHubSwitchTests +{ + private const string ConnectionId = "conn-1"; + + [Fact] + public async Task JoinBoard_ShouldLeavePreviousGroupAndRepublishOldSnapshot_WhenSwitchingWithoutLeave() + { + // Arrange + var boardA = Guid.NewGuid(); + var boardB = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var (hub, groups, groupProxies) = CreateHub(userId); + + await hub.JoinBoard(boardA); + + // Act: switch boards without calling LeaveBoard first. + await hub.JoinBoard(boardB); + + // Assert: the connection left the old group... + groups.Verify( + g => g.RemoveFromGroupAsync(ConnectionId, BoardHubGroups.ForBoard(boardA), default), + Times.Once); + + // ...the old board's observers saw the departure... + groupProxies.Should().ContainKey(BoardHubGroups.ForBoard(boardA)); + var oldSnapshots = groupProxies[BoardHubGroups.ForBoard(boardA)].Snapshots + .Where(s => s.BoardId == boardA) + .ToList(); + oldSnapshots.Should().NotBeEmpty(); + oldSnapshots.Last().Members.Should().NotContain(m => m.UserId == userId); + + // ...and the new board shows the join. + var newSnapshots = groupProxies[BoardHubGroups.ForBoard(boardB)].Snapshots + .Where(s => s.BoardId == boardB) + .ToList(); + newSnapshots.Should().ContainSingle() + .Which.Members.Should().ContainSingle(m => m.UserId == userId); + } + + [Fact] + public async Task JoinBoard_ShouldNotLeave_WhenRejoiningTheSameBoard() + { + // Arrange + var board = Guid.NewGuid(); + var (hub, groups, _) = CreateHub(Guid.NewGuid()); + + await hub.JoinBoard(board); + + // Act + await hub.JoinBoard(board); + + // Assert + groups.Verify( + g => g.RemoveFromGroupAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + groups.Verify( + g => g.AddToGroupAsync(ConnectionId, BoardHubGroups.ForBoard(board), default), + Times.Exactly(2)); + } + + [Fact] + public async Task JoinBoard_ShouldNotLeave_WhenJoiningTheFirstBoard() + { + // Arrange + var (hub, groups, _) = CreateHub(Guid.NewGuid()); + + // Act + await hub.JoinBoard(Guid.NewGuid()); + + // Assert + groups.Verify( + g => g.RemoveFromGroupAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + private static (BoardsHub Hub, Mock Groups, Dictionary Proxies) + CreateHub(Guid userId) + { + var authorizationMock = new Mock(); + authorizationMock + .Setup(a => a.CanReadBoardAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(true)); + + var tracker = new InMemoryBoardPresenceTracker(); + var hub = new BoardsHub(authorizationMock.Object, tracker); + + var claims = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim("name", "Switch Tester"), + })); + var contextMock = new Mock(); + contextMock.SetupGet(c => c.ConnectionId).Returns(ConnectionId); + contextMock.SetupGet(c => c.User).Returns(claims); + + var groupsMock = new Mock(); + var proxies = new Dictionary(StringComparer.Ordinal); + var clientsMock = new Mock(); + clientsMock + .Setup(c => c.Group(It.IsAny())) + .Returns((string groupName) => + { + if (!proxies.TryGetValue(groupName, out var proxy)) + { + proxy = new RecordingClientProxy(); + proxies[groupName] = proxy; + } + + return proxy; + }); + + hub.Context = contextMock.Object; + hub.Groups = groupsMock.Object; + hub.Clients = clientsMock.Object; + + return (hub, groupsMock, proxies); + } + + private sealed class RecordingClientProxy : ISingleClientProxy + { + public List Snapshots { get; } = new(); + + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + { + if (string.Equals(method, "boardPresence", StringComparison.Ordinal) + && args is [BoardPresenceSnapshot snapshot, ..]) + { + Snapshots.Add(snapshot); + } + + return Task.CompletedTask; + } + + public Task InvokeCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + { + return Task.FromResult(default!); + } + } +} diff --git a/backend/tests/Taskdeck.Api.Tests/InMemoryBoardPresenceTrackerTests.cs b/backend/tests/Taskdeck.Api.Tests/InMemoryBoardPresenceTrackerTests.cs index 669fedaffc..f8bdb54a1c 100644 --- a/backend/tests/Taskdeck.Api.Tests/InMemoryBoardPresenceTrackerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/InMemoryBoardPresenceTrackerTests.cs @@ -45,4 +45,27 @@ public void Leave_ShouldNotDropReverseMap_WhenConnectionNotInRequestedBoard() leaveConnectionSnapshot.Should().NotBeNull(); leaveConnectionSnapshot!.BoardId.Should().Be(boardA); } + + [Fact] + public void TryGetBoard_ShouldTrackTheObservedBoard() + { + var tracker = new InMemoryBoardPresenceTracker(); + var boardA = Guid.NewGuid(); + var boardB = Guid.NewGuid(); + const string connectionId = "conn-1"; + var userId = Guid.NewGuid(); + + tracker.TryGetBoard(connectionId, out _).Should().BeFalse(); + + tracker.Join(boardA, connectionId, userId, "user"); + tracker.TryGetBoard(connectionId, out var current).Should().BeTrue(); + current.Should().Be(boardA); + + tracker.Join(boardB, connectionId, userId, "user"); + tracker.TryGetBoard(connectionId, out current).Should().BeTrue(); + current.Should().Be(boardB); + + tracker.Leave(boardB, connectionId); + tracker.TryGetBoard(connectionId, out _).Should().BeFalse(); + } }