Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/src/Taskdeck.Api/Hubs/BoardsHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions backend/src/Taskdeck.Api/Realtime/IBoardPresenceTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ BoardPresenceSnapshot Join(
BoardPresenceSnapshot? UpdateEditingCard(Guid boardId, string connectionId, Guid? editingCardId);

bool IsConnectionJoinedBoard(string connectionId, Guid boardId);

/// <summary>The board a connection currently observes, if any.</summary>
bool TryGetBoard(string connectionId, out Guid boardId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ConnectionPresence> boardConnections)
Expand Down
156 changes: 156 additions & 0 deletions backend/tests/Taskdeck.Api.Tests/BoardsHubSwitchTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}

private static (BoardsHub Hub, Mock<IGroupManager> Groups, Dictionary<string, RecordingClientProxy> Proxies)
CreateHub(Guid userId)
{
var authorizationMock = new Mock<IAuthorizationService>();
authorizationMock
.Setup(a => a.CanReadBoardAsync(It.IsAny<Guid>(), It.IsAny<Guid>()))
.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<HubCallerContext>();
contextMock.SetupGet(c => c.ConnectionId).Returns(ConnectionId);
contextMock.SetupGet(c => c.User).Returns(claims);

var groupsMock = new Mock<IGroupManager>();
var proxies = new Dictionary<string, RecordingClientProxy>(StringComparer.Ordinal);
var clientsMock = new Mock<IHubCallerClients>();
clientsMock
.Setup(c => c.Group(It.IsAny<string>()))
.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<BoardPresenceSnapshot> 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<T> InvokeCoreAsync<T>(string method, object?[] args, CancellationToken cancellationToken = default)
{
return Task.FromResult<T>(default!);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Loading