using System.Security.Claims; using ColaFlow.API.Hubs; using ColaFlow.API.Tests.Helpers; using ColaFlow.Modules.ProjectManagement.Application.Services; using FluentAssertions; using Microsoft.AspNetCore.SignalR; using Moq; namespace ColaFlow.API.Tests.Hubs; public class ProjectHubTests { private readonly Mock _mockClients; private readonly Mock _mockGroups; private readonly Mock _mockContext; private readonly Mock _mockClientProxy; private readonly Mock _mockPermissionService; private readonly ProjectHub _hub; private readonly Guid _userId; private readonly Guid _tenantId; private const string ConnectionId = "test-connection-id"; public ProjectHubTests() { _mockClients = new Mock(); _mockGroups = new Mock(); _mockContext = new Mock(); _mockClientProxy = new Mock(); _mockPermissionService = new Mock(); _userId = Guid.NewGuid(); _tenantId = Guid.NewGuid(); // Setup context with valid claims 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); // Setup clients mock _mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(_mockClientProxy.Object); // Default: allow all permissions (individual tests can override) _mockPermissionService .Setup(s => s.IsUserProjectMemberAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); _hub = new ProjectHub(_mockPermissionService.Object) { Clients = _mockClients.Object, Groups = _mockGroups.Object, Context = _mockContext.Object }; } [Fact] public async Task JoinProject_WithValidProjectId_AddsToGroup() { // Arrange var projectId = Guid.NewGuid(); // Act await _hub.JoinProject(projectId); // Assert _mockGroups.Verify(g => g.AddToGroupAsync( ConnectionId, $"project-{projectId}", default), Times.Once); } [Fact] public async Task JoinProject_WithValidProjectId_BroadcastsUserJoined() { // Arrange var projectId = Guid.NewGuid(); // Act await _hub.JoinProject(projectId); // Assert _mockClients.Verify(c => c.OthersInGroup($"project-{projectId}"), Times.Once); _mockClientProxy.Verify(p => p.SendCoreAsync( "UserJoinedProject", It.Is(args => args.Length == 1 && args[0] != null), default), Times.Once); } [Fact] public async Task JoinProject_UserJoinedEvent_ContainsCorrectData() { // Arrange var projectId = Guid.NewGuid(); object? capturedData = null; _mockClientProxy .Setup(p => p.SendCoreAsync("UserJoinedProject", It.IsAny(), default)) .Callback((method, args, ct) => { capturedData = args[0]; }) .Returns(Task.CompletedTask); // Act await _hub.JoinProject(projectId); // Assert capturedData.Should().NotBeNull(); TestHelpers.GetPropertyValue(capturedData!, "UserId").Should().Be(_userId); TestHelpers.GetPropertyValue(capturedData!, "ProjectId").Should().Be(projectId); TestHelpers.GetPropertyValue(capturedData!, "JoinedAt").Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); } [Fact] public async Task JoinProject_MultipleProjects_AddsToMultipleGroups() { // Arrange var projectId1 = Guid.NewGuid(); var projectId2 = Guid.NewGuid(); // Act await _hub.JoinProject(projectId1); await _hub.JoinProject(projectId2); // Assert _mockGroups.Verify(g => g.AddToGroupAsync( ConnectionId, $"project-{projectId1}", default), Times.Once); _mockGroups.Verify(g => g.AddToGroupAsync( ConnectionId, $"project-{projectId2}", default), Times.Once); } [Fact] public async Task LeaveProject_WithValidProjectId_RemovesFromGroup() { // Arrange var projectId = Guid.NewGuid(); // Act await _hub.LeaveProject(projectId); // Assert _mockGroups.Verify(g => g.RemoveFromGroupAsync( ConnectionId, $"project-{projectId}", default), Times.Once); } [Fact] public async Task LeaveProject_WithValidProjectId_BroadcastsUserLeft() { // Arrange var projectId = Guid.NewGuid(); // Act await _hub.LeaveProject(projectId); // Assert _mockClients.Verify(c => c.OthersInGroup($"project-{projectId}"), Times.Once); _mockClientProxy.Verify(p => p.SendCoreAsync( "UserLeftProject", It.Is(args => args.Length == 1 && args[0] != null), default), Times.Once); } [Fact] public async Task LeaveProject_UserLeftEvent_ContainsCorrectData() { // Arrange var projectId = Guid.NewGuid(); object? capturedData = null; _mockClientProxy .Setup(p => p.SendCoreAsync("UserLeftProject", It.IsAny(), default)) .Callback((method, args, ct) => { capturedData = args[0]; }) .Returns(Task.CompletedTask); // Act await _hub.LeaveProject(projectId); // Assert capturedData.Should().NotBeNull(); TestHelpers.GetPropertyValue(capturedData!, "UserId").Should().Be(_userId); TestHelpers.GetPropertyValue(capturedData!, "ProjectId").Should().Be(projectId); TestHelpers.GetPropertyValue(capturedData!, "LeftAt").Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); } [Fact] public async Task SendTypingIndicator_WithIsTypingTrue_BroadcastsToGroup() { // Arrange var projectId = Guid.NewGuid(); var issueId = Guid.NewGuid(); // Act await _hub.SendTypingIndicator(projectId, issueId, isTyping: true); // Assert _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 SendTypingIndicator_WithIsTypingFalse_BroadcastsToGroup() { // Arrange var projectId = Guid.NewGuid(); var issueId = Guid.NewGuid(); // Act await _hub.SendTypingIndicator(projectId, issueId, isTyping: false); // Assert _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 SendTypingIndicator_ContainsCorrectData() { // Arrange var projectId = Guid.NewGuid(); var issueId = Guid.NewGuid(); var isTyping = true; object? capturedData = null; _mockClientProxy .Setup(p => p.SendCoreAsync("TypingIndicator", It.IsAny(), default)) .Callback((method, args, ct) => { capturedData = args[0]; }) .Returns(Task.CompletedTask); // Act await _hub.SendTypingIndicator(projectId, issueId, isTyping); // Assert capturedData.Should().NotBeNull(); TestHelpers.GetPropertyValue(capturedData!, "UserId").Should().Be(_userId); TestHelpers.GetPropertyValue(capturedData!, "IssueId").Should().Be(issueId); TestHelpers.GetPropertyValue(capturedData!, "IsTyping").Should().Be(isTyping); } [Fact] public async Task SendTypingIndicator_WithDifferentIssues_BroadcastsSeparately() { // Arrange var projectId = Guid.NewGuid(); var issueId1 = Guid.NewGuid(); var issueId2 = Guid.NewGuid(); // Act await _hub.SendTypingIndicator(projectId, issueId1, isTyping: true); await _hub.SendTypingIndicator(projectId, issueId2, isTyping: true); // Assert _mockClientProxy.Verify(p => p.SendCoreAsync( "TypingIndicator", It.IsAny(), default), Times.Exactly(2)); } [Fact] public async Task SendTypingIndicator_ToggleTyping_SendsBothStates() { // Arrange var projectId = Guid.NewGuid(); var issueId = Guid.NewGuid(); var capturedStates = new List(); _mockClientProxy .Setup(p => p.SendCoreAsync("TypingIndicator", It.IsAny(), default)) .Callback((method, args, ct) => { var isTyping = TestHelpers.GetPropertyValue(args[0], "IsTyping"); capturedStates.Add(isTyping); }) .Returns(Task.CompletedTask); // Act await _hub.SendTypingIndicator(projectId, issueId, isTyping: true); await _hub.SendTypingIndicator(projectId, issueId, isTyping: false); // Assert capturedStates.Should().HaveCount(2); capturedStates[0].Should().BeTrue(); capturedStates[1].Should().BeFalse(); } [Fact] public async Task JoinProject_WithoutPermission_ThrowsHubException() { // Arrange var projectId = Guid.NewGuid(); _mockPermissionService .Setup(s => s.IsUserProjectMemberAsync(_userId, projectId, It.IsAny())) .ReturnsAsync(false); // Act & Assert var act = async () => await _hub.JoinProject(projectId); await act.Should().ThrowAsync() .WithMessage("You do not have permission to access this project"); } [Fact] public async Task JoinProject_WithoutPermission_DoesNotAddToGroup() { // Arrange var projectId = Guid.NewGuid(); _mockPermissionService .Setup(s => s.IsUserProjectMemberAsync(_userId, projectId, It.IsAny())) .ReturnsAsync(false); // Act try { await _hub.JoinProject(projectId); } catch (HubException) { // Expected exception } // Assert _mockGroups.Verify(g => g.AddToGroupAsync( It.IsAny(), It.IsAny(), default), Times.Never); } [Fact] public async Task LeaveProject_WithoutPermission_ThrowsHubException() { // Arrange var projectId = Guid.NewGuid(); _mockPermissionService .Setup(s => s.IsUserProjectMemberAsync(_userId, projectId, It.IsAny())) .ReturnsAsync(false); // Act & Assert var act = async () => await _hub.LeaveProject(projectId); await act.Should().ThrowAsync() .WithMessage("You do not have permission to access this project"); } [Fact] public async Task JoinProject_VerifiesPermissionWithCorrectUserId() { // Arrange var projectId = Guid.NewGuid(); // Act await _hub.JoinProject(projectId); // Assert _mockPermissionService.Verify(s => s.IsUserProjectMemberAsync( _userId, projectId, It.IsAny()), Times.Once); } }