diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContextFactory.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContextFactory.cs
new file mode 100644
index 0000000..4112486
--- /dev/null
+++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContextFactory.cs
@@ -0,0 +1,34 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
+
+///
+/// Design-time DbContext factory for EF Core migrations
+///
+public class PMDbContextFactory : IDesignTimeDbContextFactory
+{
+ public PMDbContext CreateDbContext(string[] args)
+ {
+ // Get connection string from environment or use default matching appsettings.Development.json
+ var connectionString = Environment.GetEnvironmentVariable("PMDatabase")
+ ?? "Host=localhost;Port=5432;Database=colaflow_pm;Username=colaflow;Password=colaflow_dev_password";
+
+ // Create DbContext options
+ var optionsBuilder = new DbContextOptionsBuilder();
+ optionsBuilder.UseNpgsql(connectionString, b => b.MigrationsAssembly("ColaFlow.Modules.ProjectManagement.Infrastructure"));
+
+ // Create DbContext with a mock HttpContextAccessor (for migrations only)
+ return new PMDbContext(optionsBuilder.Options, new MockHttpContextAccessor());
+ }
+
+ ///
+ /// Mock HttpContextAccessor for design-time operations
+ /// Returns null HttpContext which PMDbContext handles gracefully
+ ///
+ private class MockHttpContextAccessor : IHttpContextAccessor
+ {
+ public HttpContext? HttpContext { get; set; } = null;
+ }
+}
diff --git a/colaflow-api/tests/ColaFlow.API.Tests/ColaFlow.API.Tests.csproj b/colaflow-api/tests/ColaFlow.API.Tests/ColaFlow.API.Tests.csproj
new file mode 100644
index 0000000..0450298
--- /dev/null
+++ b/colaflow-api/tests/ColaFlow.API.Tests/ColaFlow.API.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/colaflow-api/tests/ColaFlow.API.Tests/Helpers/TestHelpers.cs b/colaflow-api/tests/ColaFlow.API.Tests/Helpers/TestHelpers.cs
new file mode 100644
index 0000000..5b4a771
--- /dev/null
+++ b/colaflow-api/tests/ColaFlow.API.Tests/Helpers/TestHelpers.cs
@@ -0,0 +1,56 @@
+using System.Reflection;
+using System.Text.Json;
+
+namespace ColaFlow.API.Tests.Helpers;
+
+public static class TestHelpers
+{
+ ///
+ /// Get property value from an anonymous object using reflection
+ ///
+ public static T? GetPropertyValue(object obj, string propertyName)
+ {
+ var property = obj.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
+ if (property == null)
+ {
+ throw new ArgumentException($"Property '{propertyName}' not found on type '{obj.GetType().Name}'");
+ }
+
+ var value = property.GetValue(obj);
+ if (value is T typedValue)
+ {
+ return typedValue;
+ }
+
+ if (value == null && default(T) == null)
+ {
+ return default;
+ }
+
+ // Try conversion
+ try
+ {
+ return (T)Convert.ChangeType(value, typeof(T))!;
+ }
+ catch
+ {
+ throw new InvalidCastException($"Cannot convert property '{propertyName}' value '{value}' to type '{typeof(T).Name}'");
+ }
+ }
+
+ ///
+ /// Check if object has a property with a specific value
+ ///
+ public static bool HasPropertyWithValue(object obj, string propertyName, T expectedValue)
+ {
+ try
+ {
+ var actualValue = GetPropertyValue(obj, propertyName);
+ return EqualityComparer.Default.Equals(actualValue, expectedValue);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/colaflow-api/tests/ColaFlow.API.Tests/Hubs/BaseHubTests.cs b/colaflow-api/tests/ColaFlow.API.Tests/Hubs/BaseHubTests.cs
new file mode 100644
index 0000000..089b5a0
--- /dev/null
+++ b/colaflow-api/tests/ColaFlow.API.Tests/Hubs/BaseHubTests.cs
@@ -0,0 +1,249 @@
+using System.Security.Claims;
+using ColaFlow.API.Hubs;
+using FluentAssertions;
+using Microsoft.AspNetCore.SignalR;
+using Moq;
+
+namespace ColaFlow.API.Tests.Hubs;
+
+public class BaseHubTests
+{
+ private readonly Mock _mockClients;
+ private readonly Mock _mockGroups;
+ private readonly Mock _mockContext;
+ private readonly TestHub _hub;
+
+ public BaseHubTests()
+ {
+ _mockClients = new Mock();
+ _mockGroups = new Mock();
+ _mockContext = new Mock();
+
+ _hub = new TestHub
+ {
+ Clients = _mockClients.Object,
+ Groups = _mockGroups.Object,
+ Context = _mockContext.Object
+ };
+ }
+
+ [Fact]
+ public void GetCurrentUserId_WithValidSubClaim_ReturnsUserId()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var claims = new[] { new Claim("sub", userId.ToString()) };
+ _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
+
+ // Act
+ var result = _hub.GetCurrentUserIdPublic();
+
+ // Assert
+ result.Should().Be(userId);
+ }
+
+ [Fact]
+ public void GetCurrentUserId_WithValidUserIdClaim_ReturnsUserId()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var claims = new[] { new Claim("user_id", userId.ToString()) };
+ _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
+
+ // Act
+ var result = _hub.GetCurrentUserIdPublic();
+
+ // Assert
+ result.Should().Be(userId);
+ }
+
+ [Fact]
+ public void GetCurrentUserId_WithMissingClaim_ThrowsUnauthorizedException()
+ {
+ // Arrange
+ var claims = new[] { new Claim("some_other_claim", "value") };
+ _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
+
+ // Act & Assert
+ var act = () => _hub.GetCurrentUserIdPublic();
+ act.Should().Throw()
+ .WithMessage("User ID not found in token");
+ }
+
+ [Fact]
+ public void GetCurrentUserId_WithInvalidGuid_ThrowsUnauthorizedException()
+ {
+ // Arrange
+ var claims = new[] { new Claim("sub", "not-a-guid") };
+ _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
+
+ // Act & Assert
+ var act = () => _hub.GetCurrentUserIdPublic();
+ act.Should().Throw()
+ .WithMessage("User ID not found in token");
+ }
+
+ [Fact]
+ public void GetCurrentUserId_WithNullUser_ThrowsUnauthorizedException()
+ {
+ // Arrange
+ _mockContext.Setup(c => c.User).Returns((ClaimsPrincipal)null!);
+
+ // Act & Assert
+ var act = () => _hub.GetCurrentUserIdPublic();
+ act.Should().Throw()
+ .WithMessage("User ID not found in token");
+ }
+
+ [Fact]
+ public void GetCurrentTenantId_WithValidClaim_ReturnsTenantId()
+ {
+ // Arrange
+ var tenantId = Guid.NewGuid();
+ var claims = new[] { new Claim("tenant_id", tenantId.ToString()) };
+ _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
+
+ // Act
+ var result = _hub.GetCurrentTenantIdPublic();
+
+ // Assert
+ result.Should().Be(tenantId);
+ }
+
+ [Fact]
+ public void GetCurrentTenantId_WithMissingClaim_ThrowsUnauthorizedException()
+ {
+ // Arrange
+ var claims = new[] { new Claim("some_other_claim", "value") };
+ _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
+
+ // Act & Assert
+ var act = () => _hub.GetCurrentTenantIdPublic();
+ act.Should().Throw()
+ .WithMessage("Tenant ID not found in token");
+ }
+
+ [Fact]
+ public void GetCurrentTenantId_WithInvalidGuid_ThrowsUnauthorizedException()
+ {
+ // Arrange
+ var claims = new[] { new Claim("tenant_id", "not-a-guid") };
+ _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
+
+ // Act & Assert
+ var act = () => _hub.GetCurrentTenantIdPublic();
+ act.Should().Throw()
+ .WithMessage("Tenant ID not found in token");
+ }
+
+ [Fact]
+ public void GetTenantGroupName_ReturnsCorrectFormat()
+ {
+ // Arrange
+ var tenantId = Guid.NewGuid();
+
+ // Act
+ var result = _hub.GetTenantGroupNamePublic(tenantId);
+
+ // Assert
+ result.Should().Be($"tenant-{tenantId}");
+ }
+
+ [Fact]
+ public async Task OnConnectedAsync_WithValidClaims_AutoJoinsTenantGroup()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var tenantId = Guid.NewGuid();
+ var connectionId = "test-connection-id";
+
+ 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);
+
+ // Act
+ await _hub.OnConnectedAsync();
+
+ // Assert
+ _mockGroups.Verify(g => g.AddToGroupAsync(
+ connectionId,
+ $"tenant-{tenantId}",
+ default), Times.Once);
+ }
+
+ [Fact]
+ public async Task OnConnectedAsync_WithMissingTenantId_AbortsConnection()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var claims = new[] { new Claim("sub", userId.ToString()) };
+
+ _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
+ _mockContext.Setup(c => c.ConnectionId).Returns("test-connection-id");
+
+ // Act
+ await _hub.OnConnectedAsync();
+
+ // Assert
+ _mockContext.Verify(c => c.Abort(), Times.Once);
+ _mockGroups.Verify(g => g.AddToGroupAsync(
+ It.IsAny(),
+ It.IsAny(),
+ default), Times.Never);
+ }
+
+ [Fact]
+ public async Task OnConnectedAsync_WithMissingUserId_AbortsConnection()
+ {
+ // Arrange
+ var tenantId = Guid.NewGuid();
+ var claims = new[] { new Claim("tenant_id", tenantId.ToString()) };
+
+ _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims)));
+ _mockContext.Setup(c => c.ConnectionId).Returns("test-connection-id");
+
+ // Act
+ await _hub.OnConnectedAsync();
+
+ // Assert
+ _mockContext.Verify(c => c.Abort(), Times.Once);
+ _mockGroups.Verify(g => g.AddToGroupAsync(
+ It.IsAny(),
+ It.IsAny(),
+ default), Times.Never);
+ }
+
+ [Fact]
+ public async Task OnDisconnectedAsync_WithValidClaims_CompletesSuccessfully()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var tenantId = Guid.NewGuid();
+ 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)));
+
+ // Act
+ var act = async () => await _hub.OnDisconnectedAsync(null);
+
+ // Assert
+ await act.Should().NotThrowAsync();
+ }
+
+ // Test Hub implementation to expose protected methods
+ private class TestHub : BaseHub
+ {
+ public Guid GetCurrentUserIdPublic() => GetCurrentUserId();
+ public Guid GetCurrentTenantIdPublic() => GetCurrentTenantId();
+ public string GetTenantGroupNamePublic(Guid tenantId) => GetTenantGroupName(tenantId);
+ }
+}
diff --git a/colaflow-api/tests/ColaFlow.API.Tests/Hubs/NotificationHubTests.cs b/colaflow-api/tests/ColaFlow.API.Tests/Hubs/NotificationHubTests.cs
new file mode 100644
index 0000000..e6d9861
--- /dev/null
+++ b/colaflow-api/tests/ColaFlow.API.Tests/Hubs/NotificationHubTests.cs
@@ -0,0 +1,189 @@
+using System.Security.Claims;
+using ColaFlow.API.Hubs;
+using ColaFlow.API.Tests.Helpers;
+using FluentAssertions;
+using Microsoft.AspNetCore.SignalR;
+using Moq;
+
+namespace ColaFlow.API.Tests.Hubs;
+
+public class NotificationHubTests
+{
+ private readonly Mock _mockClients;
+ private readonly Mock _mockContext;
+ private readonly Mock _mockCallerProxy;
+ private readonly NotificationHub _hub;
+ private readonly Guid _userId;
+ private readonly Guid _tenantId;
+
+ public NotificationHubTests()
+ {
+ _mockClients = new Mock();
+ _mockContext = new Mock();
+ _mockCallerProxy = 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("test-connection-id");
+
+ // Setup clients mock
+ _mockClients.Setup(c => c.Caller).Returns(_mockCallerProxy.Object);
+
+ _hub = new NotificationHub
+ {
+ Clients = _mockClients.Object,
+ Context = _mockContext.Object
+ };
+ }
+
+ [Fact]
+ public async Task MarkAsRead_WithValidNotificationId_SendsNotificationReadToCaller()
+ {
+ // Arrange
+ var notificationId = Guid.NewGuid();
+
+ // Act
+ await _hub.MarkAsRead(notificationId);
+
+ // Assert
+ _mockClients.Verify(c => c.Caller, Times.Once);
+ _mockCallerProxy.Verify(p => p.SendCoreAsync(
+ "NotificationRead",
+ It.Is