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();
+ }
}