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 { } }