using System.Security.Claims;
using ColaFlow.API.Hubs;
using ColaFlow.Modules.ProjectManagement.Application.Services;
using FluentAssertions;
using Microsoft.AspNetCore.SignalR;
using Moq;
using Xunit;
namespace ColaFlow.IntegrationTests.SignalR;
///
/// Tests for SignalR multi-user collaboration scenarios
///
public class SignalRCollaborationTests
{
[Fact]
public async Task MultipleUsers_JoinSameProject_AllReceiveTypingIndicators()
{
// Arrange
var projectId = Guid.NewGuid();
var issueId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var user1Id = Guid.NewGuid();
var user2Id = Guid.NewGuid();
var user3Id = Guid.NewGuid();
var mockClients = new Mock();
var mockGroups = new Mock();
var mockClientProxy = new Mock();
var mockPermissionService = new Mock();
mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object);
mockPermissionService
.Setup(s => s.IsUserProjectMemberAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(true);
// Create hub for user 1
var hub1 = CreateProjectHub(user1Id, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object);
// Act - User 1 joins project
await hub1.JoinProject(projectId);
// User 1 sends typing indicator
await hub1.SendTypingIndicator(projectId, issueId, isTyping: true);
// Assert - Others in group receive typing indicator
mockClients.Verify(c => c.OthersInGroup($"project-{projectId}"), Times.Once);
mockClientProxy.Verify(p => p.SendCoreAsync(
"TypingIndicator",
It.Is(args => args.Length == 1),
default), Times.Once);
}
[Fact]
public async Task TwoUsers_DifferentProjects_DoNotReceiveEachOthersMessages()
{
// Arrange
var project1 = Guid.NewGuid();
var project2 = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var user1Id = Guid.NewGuid();
var user2Id = Guid.NewGuid();
var mockClients = new Mock();
var mockGroups = new Mock();
var mockClientProxy = new Mock();
var mockPermissionService = new Mock();
mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object);
mockPermissionService
.Setup(s => s.IsUserProjectMemberAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(true);
var hub1 = CreateProjectHub(user1Id, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object);
var hub2 = CreateProjectHub(user2Id, tenantId, "conn-2", mockClients.Object, mockGroups.Object, mockPermissionService.Object);
// Act - Users join different projects
await hub1.JoinProject(project1);
await hub2.JoinProject(project2);
// User 1 sends typing indicator to project 1
await hub1.SendTypingIndicator(project1, Guid.NewGuid(), isTyping: true);
// Assert - Message sent to project 1 group only
mockClients.Verify(c => c.OthersInGroup($"project-{project1}"), Times.Once);
mockClients.Verify(c => c.OthersInGroup($"project-{project2}"), Times.Never);
}
[Fact]
public async Task User_JoinThenLeaveProject_ReceivesThenStopsReceiving()
{
// Arrange
var projectId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var mockClients = new Mock();
var mockGroups = new Mock();
var mockClientProxy = new Mock();
var mockPermissionService = new Mock();
mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object);
mockPermissionService
.Setup(s => s.IsUserProjectMemberAsync(userId, projectId, It.IsAny()))
.ReturnsAsync(true);
var hub = CreateProjectHub(userId, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object);
// Act
await hub.JoinProject(projectId);
await hub.LeaveProject(projectId);
// Assert
mockGroups.Verify(g => g.AddToGroupAsync("conn-1", $"project-{projectId}", default), Times.Once);
mockGroups.Verify(g => g.RemoveFromGroupAsync("conn-1", $"project-{projectId}", default), Times.Once);
}
[Fact]
public async Task User_JoinProject_OthersNotifiedOfJoin()
{
// Arrange
var projectId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var mockClients = new Mock();
var mockGroups = new Mock();
var mockClientProxy = new Mock();
var mockPermissionService = new Mock();
object? capturedJoinEvent = null;
mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object);
mockClientProxy
.Setup(p => p.SendCoreAsync("UserJoinedProject", It.IsAny(), default))
.Callback((method, args, ct) =>
{
capturedJoinEvent = args[0];
})
.Returns(Task.CompletedTask);
mockPermissionService
.Setup(s => s.IsUserProjectMemberAsync(userId, projectId, It.IsAny()))
.ReturnsAsync(true);
var hub = CreateProjectHub(userId, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object);
// Act
await hub.JoinProject(projectId);
// Assert
capturedJoinEvent.Should().NotBeNull();
var data = capturedJoinEvent as dynamic;
((Guid)data!.UserId).Should().Be(userId);
((Guid)data.ProjectId).Should().Be(projectId);
}
[Fact]
public async Task User_LeaveProject_OthersNotifiedOfLeave()
{
// Arrange
var projectId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var mockClients = new Mock();
var mockGroups = new Mock();
var mockClientProxy = new Mock();
var mockPermissionService = new Mock();
object? capturedLeaveEvent = null;
mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object);
mockClientProxy
.Setup(p => p.SendCoreAsync("UserLeftProject", It.IsAny(), default))
.Callback((method, args, ct) =>
{
capturedLeaveEvent = args[0];
})
.Returns(Task.CompletedTask);
mockPermissionService
.Setup(s => s.IsUserProjectMemberAsync(userId, projectId, It.IsAny()))
.ReturnsAsync(true);
var hub = CreateProjectHub(userId, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object);
// Act
await hub.LeaveProject(projectId);
// Assert
capturedLeaveEvent.Should().NotBeNull();
var data = capturedLeaveEvent as dynamic;
((Guid)data!.UserId).Should().Be(userId);
((Guid)data.ProjectId).Should().Be(projectId);
}
[Fact]
public async Task MultipleUsers_SameTenant_AllAutoJoinTenantGroup()
{
// Arrange
var tenantId = Guid.NewGuid();
var user1Id = Guid.NewGuid();
var user2Id = Guid.NewGuid();
var user3Id = Guid.NewGuid();
var mockGroups = new Mock();
var hub1 = CreateTestBaseHub(user1Id, tenantId, "conn-1", mockGroups.Object);
var hub2 = CreateTestBaseHub(user2Id, tenantId, "conn-2", mockGroups.Object);
var hub3 = CreateTestBaseHub(user3Id, tenantId, "conn-3", mockGroups.Object);
// Act
await hub1.OnConnectedAsync();
await hub2.OnConnectedAsync();
await hub3.OnConnectedAsync();
// Assert
mockGroups.Verify(g => g.AddToGroupAsync("conn-1", $"tenant-{tenantId}", default), Times.Once);
mockGroups.Verify(g => g.AddToGroupAsync("conn-2", $"tenant-{tenantId}", default), Times.Once);
mockGroups.Verify(g => g.AddToGroupAsync("conn-3", $"tenant-{tenantId}", default), Times.Once);
}
[Fact]
public async Task MultipleUsers_DifferentTenants_JoinDifferentTenantGroups()
{
// Arrange
var tenant1 = Guid.NewGuid();
var tenant2 = Guid.NewGuid();
var user1Id = Guid.NewGuid();
var user2Id = Guid.NewGuid();
var mockGroups = new Mock();
var hub1 = CreateTestBaseHub(user1Id, tenant1, "conn-1", mockGroups.Object);
var hub2 = CreateTestBaseHub(user2Id, tenant2, "conn-2", mockGroups.Object);
// Act
await hub1.OnConnectedAsync();
await hub2.OnConnectedAsync();
// Assert
mockGroups.Verify(g => g.AddToGroupAsync("conn-1", $"tenant-{tenant1}", default), Times.Once);
mockGroups.Verify(g => g.AddToGroupAsync("conn-2", $"tenant-{tenant2}", default), Times.Once);
// Verify cross-tenant isolation
mockGroups.Verify(g => g.AddToGroupAsync("conn-1", $"tenant-{tenant2}", default), Times.Never);
mockGroups.Verify(g => g.AddToGroupAsync("conn-2", $"tenant-{tenant1}", default), Times.Never);
}
[Fact]
public async Task User_SendsTypingStart_ThenStop_SendsBothEvents()
{
// Arrange
var projectId = Guid.NewGuid();
var issueId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var mockClients = new Mock();
var mockGroups = new Mock();
var mockClientProxy = new Mock();
var mockPermissionService = new Mock();
var typingStates = new List();
mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object);
mockClientProxy
.Setup(p => p.SendCoreAsync("TypingIndicator", It.IsAny(), default))
.Callback((method, args, ct) =>
{
var data = args[0] as dynamic;
typingStates.Add((bool)data!.IsTyping);
})
.Returns(Task.CompletedTask);
mockPermissionService
.Setup(s => s.IsUserProjectMemberAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(true);
var hub = CreateProjectHub(userId, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object);
// Act
await hub.SendTypingIndicator(projectId, issueId, isTyping: true);
await hub.SendTypingIndicator(projectId, issueId, isTyping: false);
// Assert
typingStates.Should().HaveCount(2);
typingStates[0].Should().BeTrue();
typingStates[1].Should().BeFalse();
}
// Helper methods
private ProjectHub CreateProjectHub(
Guid userId,
Guid tenantId,
string connectionId,
IHubCallerClients clients,
IGroupManager groups,
IProjectPermissionService permissionService)
{
var mockContext = new Mock();
var claims = new[]
{
new Claim("sub", userId.ToString()),
new Claim("tenant_id", tenantId.ToString())
};
mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
mockContext.Setup(c => c.ConnectionId).Returns(connectionId);
mockContext.Setup(c => c.ConnectionAborted).Returns(CancellationToken.None);
return new ProjectHub(permissionService)
{
Context = mockContext.Object,
Clients = clients,
Groups = groups
};
}
private TestBaseHub CreateTestBaseHub(
Guid userId,
Guid tenantId,
string connectionId,
IGroupManager groups)
{
var mockContext = new Mock();
var claims = new[]
{
new Claim("sub", userId.ToString()),
new Claim("tenant_id", tenantId.ToString())
};
mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
mockContext.Setup(c => c.ConnectionId).Returns(connectionId);
return new TestBaseHub
{
Context = mockContext.Object,
Groups = groups
};
}
private class TestBaseHub : BaseHub { }
}