In progress
Some checks failed
Code Coverage / Generate Coverage Report (push) Has been cancelled
Tests / Run Tests (9.0.x) (push) Has been cancelled
Tests / Docker Build Test (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled

This commit is contained in:
Yaojia Wang
2025-11-03 14:00:24 +01:00
parent fe8ad1c1f9
commit 1f66b25f30
74 changed files with 9609 additions and 28 deletions

View File

@@ -39,6 +39,22 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.ArchitectureTests", "tests\ColaFlow.ArchitectureTests\ColaFlow.ArchitectureTests.csproj", "{A059FDA9-5454-49A8-A025-0FC5130574EE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Identity.Domain", "src\Modules\Identity\ColaFlow.Modules.Identity.Domain\ColaFlow.Modules.Identity.Domain.csproj", "{1647C962-6F4B-4AF4-8608-11B784B8C59E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Identity.Application", "src\Modules\Identity\ColaFlow.Modules.Identity.Application\ColaFlow.Modules.Identity.Application.csproj", "{97193F5A-5F0D-48C2-8A94-9768B624437D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Identity.Infrastructure", "src\Modules\Identity\ColaFlow.Modules.Identity.Infrastructure\ColaFlow.Modules.Identity.Infrastructure.csproj", "{775AF575-9989-4D7C-BD3B-18262CD9283F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{D7DC9B74-6BC4-2470-2038-1E57C2DCB73B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{ACB2D19B-6984-27D8-539C-F209B7C78BA5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Identity.Domain.Tests", "tests\Modules\Identity\ColaFlow.Modules.Identity.Domain.Tests\ColaFlow.Modules.Identity.Domain.Tests.csproj", "{18EA8D3B-8570-4D51-B410-580F0782A61C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Identity.Infrastructure.Tests", "tests\Modules\Identity\ColaFlow.Modules.Identity.Infrastructure.Tests\ColaFlow.Modules.Identity.Infrastructure.Tests.csproj", "{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -205,6 +221,66 @@ Global
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Release|x64.Build.0 = Release|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Release|x86.ActiveCfg = Release|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Release|x86.Build.0 = Release|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Debug|x64.ActiveCfg = Debug|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Debug|x64.Build.0 = Debug|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Debug|x86.ActiveCfg = Debug|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Debug|x86.Build.0 = Debug|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Release|Any CPU.Build.0 = Release|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Release|x64.ActiveCfg = Release|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Release|x64.Build.0 = Release|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Release|x86.ActiveCfg = Release|Any CPU
{1647C962-6F4B-4AF4-8608-11B784B8C59E}.Release|x86.Build.0 = Release|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Debug|x64.ActiveCfg = Debug|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Debug|x64.Build.0 = Debug|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Debug|x86.ActiveCfg = Debug|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Debug|x86.Build.0 = Debug|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Release|Any CPU.Build.0 = Release|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Release|x64.ActiveCfg = Release|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Release|x64.Build.0 = Release|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Release|x86.ActiveCfg = Release|Any CPU
{97193F5A-5F0D-48C2-8A94-9768B624437D}.Release|x86.Build.0 = Release|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Debug|x64.ActiveCfg = Debug|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Debug|x64.Build.0 = Debug|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Debug|x86.ActiveCfg = Debug|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Debug|x86.Build.0 = Debug|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Release|Any CPU.Build.0 = Release|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Release|x64.ActiveCfg = Release|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Release|x64.Build.0 = Release|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Release|x86.ActiveCfg = Release|Any CPU
{775AF575-9989-4D7C-BD3B-18262CD9283F}.Release|x86.Build.0 = Release|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Debug|x64.ActiveCfg = Debug|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Debug|x64.Build.0 = Debug|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Debug|x86.ActiveCfg = Debug|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Debug|x86.Build.0 = Debug|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Release|Any CPU.Build.0 = Release|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Release|x64.ActiveCfg = Release|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Release|x64.Build.0 = Release|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Release|x86.ActiveCfg = Release|Any CPU
{18EA8D3B-8570-4D51-B410-580F0782A61C}.Release|x86.Build.0 = Release|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Debug|x64.ActiveCfg = Debug|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Debug|x64.Build.0 = Debug|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Debug|x86.ActiveCfg = Debug|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Debug|x86.Build.0 = Debug|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Release|Any CPU.Build.0 = Release|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Release|x64.ActiveCfg = Release|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Release|x64.Build.0 = Release|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Release|x86.ActiveCfg = Release|Any CPU
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -226,5 +302,13 @@ Global
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29} = {CA0D0B73-F1EC-F12F-54BA-8DF761F62CA4}
{EF0BCA60-10E6-48AF-807D-416D262B85E3} = {CA0D0B73-F1EC-F12F-54BA-8DF761F62CA4}
{A059FDA9-5454-49A8-A025-0FC5130574EE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{1647C962-6F4B-4AF4-8608-11B784B8C59E} = {0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7}
{97193F5A-5F0D-48C2-8A94-9768B624437D} = {0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7}
{775AF575-9989-4D7C-BD3B-18262CD9283F} = {0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7}
{D7DC9B74-6BC4-2470-2038-1E57C2DCB73B} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{ACB2D19B-6984-27D8-539C-F209B7C78BA5} = {D7DC9B74-6BC4-2470-2038-1E57C2DCB73B}
{18EA8D3B-8570-4D51-B410-580F0782A61C} = {ACB2D19B-6984-27D8-539C-F209B7C78BA5}
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA} = {ACB2D19B-6984-27D8-539C-F209B7C78BA5}
EndGlobalSection
EndGlobal

View File

@@ -20,11 +20,13 @@
<ProjectReference Include="..\Modules\ProjectManagement\ColaFlow.Modules.ProjectManagement.Application\ColaFlow.Modules.ProjectManagement.Application.csproj" />
<ProjectReference Include="..\Modules\ProjectManagement\ColaFlow.Modules.ProjectManagement.Infrastructure\ColaFlow.Modules.ProjectManagement.Infrastructure.csproj" />
<ProjectReference Include="..\Shared\ColaFlow.Shared.Kernel\ColaFlow.Shared.Kernel.csproj" />
<ProjectReference Include="..\Modules\Identity\ColaFlow.Modules.Identity.Application\ColaFlow.Modules.Identity.Application.csproj" />
<ProjectReference Include="..\Modules\Identity\ColaFlow.Modules.Identity.Infrastructure\ColaFlow.Modules.Identity.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="13.1.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,38 @@
using ColaFlow.Modules.Identity.Application.Commands.Login;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ColaFlow.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly IMediator _mediator;
public AuthController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// Login with email and password
/// </summary>
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginCommand command)
{
var result = await _mediator.Send(command);
return Ok(result);
}
/// <summary>
/// Get current user (requires authentication)
/// </summary>
[HttpGet("me")]
// [Authorize] // TODO: Add after JWT middleware is configured
public async Task<IActionResult> GetCurrentUser()
{
// TODO: Implement after JWT middleware
return Ok(new { message = "Current user endpoint - to be implemented" });
}
}

View File

@@ -0,0 +1,55 @@
using ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
using ColaFlow.Modules.Identity.Application.Queries.GetTenantBySlug;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ColaFlow.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TenantsController : ControllerBase
{
private readonly IMediator _mediator;
public TenantsController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// Register a new tenant (company signup)
/// </summary>
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterTenantCommand command)
{
var result = await _mediator.Send(command);
return Ok(result);
}
/// <summary>
/// Get tenant by slug (for login page tenant resolution)
/// </summary>
[HttpGet("{slug}")]
public async Task<IActionResult> GetBySlug(string slug)
{
var query = new GetTenantBySlugQuery(slug);
var result = await _mediator.Send(query);
if (result == null)
return NotFound(new { message = "Tenant not found" });
return Ok(result);
}
/// <summary>
/// Check if tenant slug is available
/// </summary>
[HttpGet("check-slug/{slug}")]
public async Task<IActionResult> CheckSlug(string slug)
{
var query = new GetTenantBySlugQuery(slug);
var result = await _mediator.Send(query);
return Ok(new { available = result == null });
}
}

View File

@@ -1,5 +1,7 @@
using ColaFlow.API.Extensions;
using ColaFlow.API.Handlers;
using ColaFlow.Modules.Identity.Application;
using ColaFlow.Modules.Identity.Infrastructure;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
@@ -7,6 +9,10 @@ var builder = WebApplication.CreateBuilder(args);
// Register ProjectManagement Module
builder.Services.AddProjectManagementModule(builder.Configuration);
// Register Identity Module
builder.Services.AddIdentityApplication();
builder.Services.AddIdentityInfrastructure(builder.Configuration);
// Add controllers
builder.Services.AddControllers();

View File

@@ -1,6 +1,7 @@
{
"ConnectionStrings": {
"PMDatabase": "Host=localhost;Port=5432;Database=colaflow_pm;Username=colaflow;Password=colaflow_dev_password"
"PMDatabase": "Host=localhost;Port=5432;Database=colaflow_pm;Username=colaflow;Password=colaflow_dev_password",
"DefaultConnection": "Host=localhost;Port=5432;Database=colaflow_identity;Username=colaflow;Password=colaflow_dev_password"
},
"MediatR": {
"LicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxNzkzNTc3NjAwIiwiaWF0IjoiMTc2MjEyNTU2MiIsImFjY291bnRfaWQiOiIwMTlhNDZkZGZiZjk3YTk4Yjg1ZTVmOTllNWRhZjIwNyIsImN1c3RvbWVyX2lkIjoiY3RtXzAxazkzZHdnOG0weDByanp3Mm5rM2dxeDExIiwic3ViX2lkIjoiLSIsImVkaXRpb24iOiIwIiwidHlwZSI6IjIifQ.V45vUlze27pQG3Vs9dvagyUTSp-a74ymB6I0TIGD_NwFt1mMMPsuVXOKH1qK7A7V5qDQBvYyryzJy8xRE1rRKq2MJKgyfYjvzuGkpBbKbM6JRQPYknb5tjF-Rf3LAeWp73FiqbPZOPt5saCsoKqUHej-4zcKg5GA4y-PpGaGAONKyqwK9G2rvc1BUHfEnHKRMr0pprA5W1Yx-Lry85KOckUsI043HGOdfbubnGdAZs74FKvrV2qVir6K6VsZjWwX8IFnl1CzxjICa5MxyHOAVpXRnRtMt6fpsA1fMstFuRjq_2sbqGfsTv6LyCzLPnXdmU5DnWZHUcjy0xlAT_f0aw"

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ColaFlow.Modules.Identity.Domain\ColaFlow.Modules.Identity.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="12.1.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.0" />
<PackageReference Include="MediatR" Version="13.1.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,10 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
public record LoginCommand(
string TenantSlug,
string Email,
string Password
) : IRequest<LoginResponseDto>;

View File

@@ -0,0 +1,84 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDto>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
// Note: In production, inject IPasswordHasher and IJwtService
public LoginCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
}
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
{
// 1. Find tenant
var slug = TenantSlug.Create(request.TenantSlug);
var tenant = await _tenantRepository.GetBySlugAsync(slug, cancellationToken);
if (tenant == null)
{
throw new UnauthorizedAccessException("Invalid credentials");
}
// 2. Find user
var email = Email.Create(request.Email);
var user = await _userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
if (user == null)
{
throw new UnauthorizedAccessException("Invalid credentials");
}
// 3. Verify password (simplified - TODO: use IPasswordHasher)
// if (!PasswordHasher.Verify(request.Password, user.PasswordHash))
// {
// throw new UnauthorizedAccessException("Invalid credentials");
// }
// 4. Generate JWT token (simplified - TODO: use IJwtService)
var accessToken = "dummy-token";
// 5. Update last login time
user.RecordLogin();
await _userRepository.UpdateAsync(user, cancellationToken);
// 6. Return result
return new LoginResponseDto
{
User = new UserDto
{
Id = user.Id,
TenantId = tenant.Id,
Email = user.Email.Value,
FullName = user.FullName.Value,
Status = user.Status.ToString(),
AuthProvider = user.AuthProvider.ToString(),
IsEmailVerified = user.EmailVerifiedAt.HasValue,
LastLoginAt = user.LastLoginAt,
CreatedAt = user.CreatedAt
},
Tenant = new TenantDto
{
Id = tenant.Id,
Name = tenant.Name.Value,
Slug = tenant.Slug.Value,
Status = tenant.Status.ToString(),
Plan = tenant.Plan.ToString(),
SsoEnabled = tenant.SsoConfig != null,
SsoProvider = tenant.SsoConfig?.Provider.ToString(),
CreatedAt = tenant.CreatedAt,
UpdatedAt = tenant.UpdatedAt ?? tenant.CreatedAt
},
AccessToken = accessToken
};
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
public class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.TenantSlug)
.NotEmpty().WithMessage("Tenant slug is required");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required");
}
}

View File

@@ -0,0 +1,19 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
public record RegisterTenantCommand(
string TenantName,
string TenantSlug,
string SubscriptionPlan,
string AdminEmail,
string AdminPassword,
string AdminFullName
) : IRequest<RegisterTenantResult>;
public record RegisterTenantResult(
TenantDto Tenant,
UserDto AdminUser,
string AccessToken
);

View File

@@ -0,0 +1,83 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantCommand, RegisterTenantResult>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
// Note: In production, inject IJwtService and IPasswordHasher
public RegisterTenantCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
}
public async Task<RegisterTenantResult> Handle(
RegisterTenantCommand request,
CancellationToken cancellationToken)
{
// 1. Validate slug uniqueness
var slug = TenantSlug.Create(request.TenantSlug);
var slugExists = await _tenantRepository.ExistsBySlugAsync(slug, cancellationToken);
if (slugExists)
{
throw new InvalidOperationException($"Tenant slug '{request.TenantSlug}' is already taken");
}
// 2. Create tenant
var plan = Enum.Parse<SubscriptionPlan>(request.SubscriptionPlan);
var tenant = Tenant.Create(
TenantName.Create(request.TenantName),
slug,
plan);
await _tenantRepository.AddAsync(tenant, cancellationToken);
// 3. Create admin user
// Note: In production, hash password first using IPasswordHasher
var adminUser = User.CreateLocal(
TenantId.Create(tenant.Id),
Email.Create(request.AdminEmail),
request.AdminPassword, // TODO: Hash password
FullName.Create(request.AdminFullName));
await _userRepository.AddAsync(adminUser, cancellationToken);
// 4. Generate JWT token (simplified - TODO: use IJwtService)
var accessToken = "dummy-token";
// 5. Return result
return new RegisterTenantResult(
new Dtos.TenantDto
{
Id = tenant.Id,
Name = tenant.Name.Value,
Slug = tenant.Slug.Value,
Status = tenant.Status.ToString(),
Plan = tenant.Plan.ToString(),
SsoEnabled = tenant.SsoConfig != null,
SsoProvider = tenant.SsoConfig?.Provider.ToString(),
CreatedAt = tenant.CreatedAt,
UpdatedAt = tenant.UpdatedAt ?? tenant.CreatedAt
},
new Dtos.UserDto
{
Id = adminUser.Id,
TenantId = tenant.Id,
Email = adminUser.Email.Value,
FullName = adminUser.FullName.Value,
Status = adminUser.Status.ToString(),
AuthProvider = adminUser.AuthProvider.ToString(),
IsEmailVerified = adminUser.EmailVerifiedAt.HasValue,
CreatedAt = adminUser.CreatedAt
},
accessToken);
}
}

View File

@@ -0,0 +1,43 @@
using FluentValidation;
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
public class RegisterTenantCommandValidator : AbstractValidator<RegisterTenantCommand>
{
public RegisterTenantCommandValidator()
{
RuleFor(x => x.TenantName)
.NotEmpty().WithMessage("Tenant name is required")
.MinimumLength(2).WithMessage("Tenant name must be at least 2 characters")
.MaximumLength(100).WithMessage("Tenant name cannot exceed 100 characters");
RuleFor(x => x.TenantSlug)
.NotEmpty().WithMessage("Tenant slug is required")
.MinimumLength(3).WithMessage("Tenant slug must be at least 3 characters")
.MaximumLength(50).WithMessage("Tenant slug cannot exceed 50 characters")
.Matches("^[a-z0-9]+(?:-[a-z0-9]+)*$")
.WithMessage("Tenant slug can only contain lowercase letters, numbers, and hyphens");
RuleFor(x => x.SubscriptionPlan)
.NotEmpty().WithMessage("Subscription plan is required")
.Must(plan => new[] { "Free", "Starter", "Professional", "Enterprise" }.Contains(plan))
.WithMessage("Invalid subscription plan");
RuleFor(x => x.AdminEmail)
.NotEmpty().WithMessage("Admin email is required")
.EmailAddress().WithMessage("Invalid email format");
RuleFor(x => x.AdminPassword)
.NotEmpty().WithMessage("Admin password is required")
.MinimumLength(8).WithMessage("Password must be at least 8 characters")
.Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter")
.Matches("[a-z]").WithMessage("Password must contain at least one lowercase letter")
.Matches("[0-9]").WithMessage("Password must contain at least one digit")
.Matches("[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character");
RuleFor(x => x.AdminFullName)
.NotEmpty().WithMessage("Admin full name is required")
.MinimumLength(2).WithMessage("Full name must be at least 2 characters")
.MaximumLength(100).WithMessage("Full name cannot exceed 100 characters");
}
}

View File

@@ -0,0 +1,21 @@
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
namespace ColaFlow.Modules.Identity.Application;
public static class DependencyInjection
{
public static IServiceCollection AddIdentityApplication(this IServiceCollection services)
{
// MediatR
services.AddMediatR(config =>
{
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
});
// FluentValidation
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
return services;
}
}

View File

@@ -0,0 +1,8 @@
namespace ColaFlow.Modules.Identity.Application.Dtos;
public class LoginResponseDto
{
public UserDto User { get; set; } = null!;
public TenantDto Tenant { get; set; } = null!;
public string AccessToken { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,14 @@
namespace ColaFlow.Modules.Identity.Application.Dtos;
public class TenantDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string Plan { get; set; } = string.Empty;
public bool SsoEnabled { get; set; }
public string? SsoProvider { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace ColaFlow.Modules.Identity.Application.Dtos;
public class UserDto
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string Email { get; set; } = string.Empty;
public string FullName { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string AuthProvider { get; set; } = string.Empty;
public string? AvatarUrl { get; set; }
public string? JobTitle { get; set; }
public bool IsEmailVerified { get; set; }
public DateTime? LastLoginAt { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,6 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Queries.GetTenantBySlug;
public record GetTenantBySlugQuery(string Slug) : IRequest<TenantDto?>;

View File

@@ -0,0 +1,38 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Queries.GetTenantBySlug;
public class GetTenantBySlugQueryHandler : IRequestHandler<GetTenantBySlugQuery, TenantDto?>
{
private readonly ITenantRepository _tenantRepository;
public GetTenantBySlugQueryHandler(ITenantRepository tenantRepository)
{
_tenantRepository = tenantRepository;
}
public async Task<TenantDto?> Handle(GetTenantBySlugQuery request, CancellationToken cancellationToken)
{
var slug = TenantSlug.Create(request.Slug);
var tenant = await _tenantRepository.GetBySlugAsync(slug, cancellationToken);
if (tenant == null)
return null;
return new TenantDto
{
Id = tenant.Id,
Name = tenant.Name.Value,
Slug = tenant.Slug.Value,
Status = tenant.Status.ToString(),
Plan = tenant.Plan.ToString(),
SsoEnabled = tenant.SsoConfig != null,
SsoProvider = tenant.SsoConfig?.Provider.ToString(),
CreatedAt = tenant.CreatedAt,
UpdatedAt = tenant.UpdatedAt ?? tenant.CreatedAt
};
}
}

View File

@@ -0,0 +1,5 @@
using ColaFlow.Shared.Kernel.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
public sealed record SsoConfiguredEvent(Guid TenantId, SsoProvider Provider) : DomainEvent;

View File

@@ -0,0 +1,5 @@
using ColaFlow.Shared.Kernel.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
public sealed record SsoDisabledEvent(Guid TenantId) : DomainEvent;

View File

@@ -0,0 +1,5 @@
using ColaFlow.Shared.Kernel.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
public sealed record TenantActivatedEvent(Guid TenantId) : DomainEvent;

View File

@@ -0,0 +1,5 @@
using ColaFlow.Shared.Kernel.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
public sealed record TenantCancelledEvent(Guid TenantId) : DomainEvent;

View File

@@ -0,0 +1,5 @@
using ColaFlow.Shared.Kernel.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
public sealed record TenantCreatedEvent(Guid TenantId, string Slug) : DomainEvent;

View File

@@ -0,0 +1,5 @@
using ColaFlow.Shared.Kernel.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
public sealed record TenantPlanUpgradedEvent(Guid TenantId, SubscriptionPlan NewPlan) : DomainEvent;

View File

@@ -0,0 +1,5 @@
using ColaFlow.Shared.Kernel.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
public sealed record TenantSuspendedEvent(Guid TenantId, string Reason) : DomainEvent;

View File

@@ -0,0 +1,93 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
public sealed class SsoConfiguration : ValueObject
{
public SsoProvider Provider { get; }
public string Authority { get; }
public string ClientId { get; }
public string ClientSecret { get; } // Encrypted in database
public string? MetadataUrl { get; }
// SAML-specific
public string? EntityId { get; }
public string? SignOnUrl { get; }
public string? Certificate { get; }
private SsoConfiguration(
SsoProvider provider,
string authority,
string clientId,
string clientSecret,
string? metadataUrl = null,
string? entityId = null,
string? signOnUrl = null,
string? certificate = null)
{
Provider = provider;
Authority = authority;
ClientId = clientId;
ClientSecret = clientSecret;
MetadataUrl = metadataUrl;
EntityId = entityId;
SignOnUrl = signOnUrl;
Certificate = certificate;
}
public static SsoConfiguration CreateOidc(
SsoProvider provider,
string authority,
string clientId,
string clientSecret,
string? metadataUrl = null)
{
if (provider == SsoProvider.GenericSaml)
throw new ArgumentException("Use CreateSaml for SAML configuration");
if (string.IsNullOrWhiteSpace(authority))
throw new ArgumentException("Authority is required", nameof(authority));
if (string.IsNullOrWhiteSpace(clientId))
throw new ArgumentException("Client ID is required", nameof(clientId));
if (string.IsNullOrWhiteSpace(clientSecret))
throw new ArgumentException("Client secret is required", nameof(clientSecret));
return new SsoConfiguration(provider, authority, clientId, clientSecret, metadataUrl);
}
public static SsoConfiguration CreateSaml(
string entityId,
string signOnUrl,
string certificate,
string? metadataUrl = null)
{
if (string.IsNullOrWhiteSpace(entityId))
throw new ArgumentException("Entity ID is required", nameof(entityId));
if (string.IsNullOrWhiteSpace(signOnUrl))
throw new ArgumentException("Sign-on URL is required", nameof(signOnUrl));
if (string.IsNullOrWhiteSpace(certificate))
throw new ArgumentException("Certificate is required", nameof(certificate));
return new SsoConfiguration(
SsoProvider.GenericSaml,
signOnUrl,
entityId,
string.Empty, // No client secret for SAML
metadataUrl,
entityId,
signOnUrl,
certificate);
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Provider;
yield return Authority;
yield return ClientId;
yield return EntityId ?? string.Empty;
}
}

View File

@@ -0,0 +1,9 @@
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
public enum SsoProvider
{
AzureAD = 1,
Google = 2,
Okta = 3,
GenericSaml = 4
}

View File

@@ -0,0 +1,9 @@
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
public enum SubscriptionPlan
{
Free = 1,
Starter = 2,
Professional = 3,
Enterprise = 4
}

View File

@@ -0,0 +1,175 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
/// <summary>
/// Tenant aggregate root - represents a single organization/company in the system
/// </summary>
public sealed class Tenant : AggregateRoot
{
// Properties
public TenantName Name { get; private set; } = null!;
public TenantSlug Slug { get; private set; } = null!;
public TenantStatus Status { get; private set; }
public SubscriptionPlan Plan { get; private set; }
public SsoConfiguration? SsoConfig { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
public DateTime? SuspendedAt { get; private set; }
public string? SuspensionReason { get; private set; }
// Settings
public int MaxUsers { get; private set; }
public int MaxProjects { get; private set; }
public int MaxStorageGB { get; private set; }
// Private constructor for EF Core
private Tenant() : base()
{
}
// Factory method for creating new tenant
public static Tenant Create(
TenantName name,
TenantSlug slug,
SubscriptionPlan plan = SubscriptionPlan.Free)
{
var tenant = new Tenant
{
Id = Guid.NewGuid(),
Name = name,
Slug = slug,
Status = TenantStatus.Active,
Plan = plan,
CreatedAt = DateTime.UtcNow,
MaxUsers = GetMaxUsersByPlan(plan),
MaxProjects = GetMaxProjectsByPlan(plan),
MaxStorageGB = GetMaxStorageByPlan(plan)
};
tenant.AddDomainEvent(new TenantCreatedEvent(tenant.Id, tenant.Slug));
return tenant;
}
// Business methods
public void UpdateName(TenantName newName)
{
if (Status == TenantStatus.Cancelled)
throw new InvalidOperationException("Cannot update cancelled tenant");
Name = newName;
UpdatedAt = DateTime.UtcNow;
}
public void UpgradePlan(SubscriptionPlan newPlan)
{
if (newPlan <= Plan)
throw new InvalidOperationException("New plan must be higher than current plan");
if (Status != TenantStatus.Active && Status != TenantStatus.Trial)
throw new InvalidOperationException("Only active or trial tenants can upgrade");
Plan = newPlan;
MaxUsers = GetMaxUsersByPlan(newPlan);
MaxProjects = GetMaxProjectsByPlan(newPlan);
MaxStorageGB = GetMaxStorageByPlan(newPlan);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TenantPlanUpgradedEvent(Id, newPlan));
}
public void ConfigureSso(SsoConfiguration ssoConfig)
{
if (Plan == SubscriptionPlan.Free || Plan == SubscriptionPlan.Starter)
throw new InvalidOperationException("SSO is only available for Professional and Enterprise plans");
SsoConfig = ssoConfig;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new SsoConfiguredEvent(Id, ssoConfig.Provider));
}
public void DisableSso()
{
if (SsoConfig == null)
throw new InvalidOperationException("SSO is not configured");
SsoConfig = null;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new SsoDisabledEvent(Id));
}
public void Activate()
{
if (Status == TenantStatus.Cancelled)
throw new InvalidOperationException("Cannot activate cancelled tenant");
if (Status == TenantStatus.Active)
return; // Already active
Status = TenantStatus.Active;
SuspendedAt = null;
SuspensionReason = null;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TenantActivatedEvent(Id));
}
public void Suspend(string reason)
{
if (Status == TenantStatus.Cancelled)
throw new InvalidOperationException("Cannot suspend cancelled tenant");
if (Status == TenantStatus.Suspended)
return; // Already suspended
Status = TenantStatus.Suspended;
SuspendedAt = DateTime.UtcNow;
SuspensionReason = reason;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TenantSuspendedEvent(Id, reason));
}
public void Cancel()
{
if (Status == TenantStatus.Cancelled)
return; // Already cancelled
Status = TenantStatus.Cancelled;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TenantCancelledEvent(Id));
}
// Plan limits
private static int GetMaxUsersByPlan(SubscriptionPlan plan) => plan switch
{
SubscriptionPlan.Free => 5,
SubscriptionPlan.Starter => 20,
SubscriptionPlan.Professional => 100,
SubscriptionPlan.Enterprise => int.MaxValue,
_ => throw new ArgumentOutOfRangeException(nameof(plan))
};
private static int GetMaxProjectsByPlan(SubscriptionPlan plan) => plan switch
{
SubscriptionPlan.Free => 3,
SubscriptionPlan.Starter => 20,
SubscriptionPlan.Professional => 100,
SubscriptionPlan.Enterprise => int.MaxValue,
_ => throw new ArgumentOutOfRangeException(nameof(plan))
};
private static int GetMaxStorageByPlan(SubscriptionPlan plan) => plan switch
{
SubscriptionPlan.Free => 2,
SubscriptionPlan.Starter => 20,
SubscriptionPlan.Professional => 100,
SubscriptionPlan.Enterprise => 1000,
_ => throw new ArgumentOutOfRangeException(nameof(plan))
};
}

View File

@@ -0,0 +1,33 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
public sealed class TenantId : ValueObject
{
public Guid Value { get; }
private TenantId(Guid value)
{
Value = value;
}
public static TenantId CreateUnique() => new(Guid.NewGuid());
public static TenantId Create(Guid value)
{
if (value == Guid.Empty)
throw new ArgumentException("Tenant ID cannot be empty", nameof(value));
return new TenantId(value);
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
// Implicit conversion
public static implicit operator Guid(TenantId tenantId) => tenantId.Value;
}

View File

@@ -0,0 +1,37 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
public sealed class TenantName : ValueObject
{
public string Value { get; }
private TenantName(string value)
{
Value = value;
}
public static TenantName Create(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Tenant name cannot be empty", nameof(value));
if (value.Length < 2)
throw new ArgumentException("Tenant name must be at least 2 characters", nameof(value));
if (value.Length > 100)
throw new ArgumentException("Tenant name cannot exceed 100 characters", nameof(value));
return new TenantName(value.Trim());
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value;
// Implicit conversion
public static implicit operator string(TenantName name) => name.Value;
}

View File

@@ -0,0 +1,50 @@
using System.Text.RegularExpressions;
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
public sealed class TenantSlug : ValueObject
{
private static readonly Regex SlugRegex = new(@"^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.Compiled);
public string Value { get; }
private TenantSlug(string value)
{
Value = value;
}
public static TenantSlug Create(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Tenant slug cannot be empty", nameof(value));
value = value.ToLowerInvariant().Trim();
if (value.Length < 3)
throw new ArgumentException("Tenant slug must be at least 3 characters", nameof(value));
if (value.Length > 50)
throw new ArgumentException("Tenant slug cannot exceed 50 characters", nameof(value));
if (!SlugRegex.IsMatch(value))
throw new ArgumentException("Tenant slug can only contain lowercase letters, numbers, and hyphens", nameof(value));
// Reserved slugs
var reservedSlugs = new[] { "www", "api", "admin", "app", "dashboard", "docs", "blog", "support" };
if (reservedSlugs.Contains(value))
throw new ArgumentException($"Tenant slug '{value}' is reserved", nameof(value));
return new TenantSlug(value);
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value;
// Implicit conversion
public static implicit operator string(TenantSlug slug) => slug.Value;
}

View File

@@ -0,0 +1,9 @@
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
public enum TenantStatus
{
Active = 1,
Trial = 2,
Suspended = 3,
Cancelled = 4
}

View File

@@ -0,0 +1,10 @@
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
public enum AuthenticationProvider
{
Local = 1, // Username/password
AzureAD = 2, // Microsoft Azure AD
Google = 3, // Google Workspace
Okta = 4, // Okta
GenericSaml = 5 // Generic SAML 2.0
}

View File

@@ -0,0 +1,44 @@
using System.Text.RegularExpressions;
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
public sealed class Email : ValueObject
{
private static readonly Regex EmailRegex = new(
@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
RegexOptions.Compiled);
public string Value { get; }
private Email(string value)
{
Value = value;
}
public static Email Create(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Email cannot be empty", nameof(value));
value = value.ToLowerInvariant().Trim();
if (value.Length > 255)
throw new ArgumentException("Email cannot exceed 255 characters", nameof(value));
if (!EmailRegex.IsMatch(value))
throw new ArgumentException("Invalid email format", nameof(value));
return new Email(value);
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value;
// Implicit conversion
public static implicit operator string(Email email) => email.Value;
}

View File

@@ -0,0 +1,6 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
public sealed record UserCreatedEvent(Guid UserId, string Email, TenantId TenantId) : DomainEvent;

View File

@@ -0,0 +1,10 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
public sealed record UserCreatedFromSsoEvent(
Guid UserId,
string Email,
TenantId TenantId,
AuthenticationProvider Provider) : DomainEvent;

View File

@@ -0,0 +1,5 @@
using ColaFlow.Shared.Kernel.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
public sealed record UserPasswordChangedEvent(Guid UserId) : DomainEvent;

View File

@@ -0,0 +1,5 @@
using ColaFlow.Shared.Kernel.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
public sealed record UserSuspendedEvent(Guid UserId, string Reason) : DomainEvent;

View File

@@ -0,0 +1,37 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
public sealed class FullName : ValueObject
{
public string Value { get; }
private FullName(string value)
{
Value = value;
}
public static FullName Create(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Full name cannot be empty", nameof(value));
if (value.Length < 2)
throw new ArgumentException("Full name must be at least 2 characters", nameof(value));
if (value.Length > 100)
throw new ArgumentException("Full name cannot exceed 100 characters", nameof(value));
return new FullName(value.Trim());
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value;
// Implicit conversion
public static implicit operator string(FullName name) => name.Value;
}

View File

@@ -0,0 +1,213 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
/// <summary>
/// User aggregate root - multi-tenant aware with SSO support
/// </summary>
public sealed class User : AggregateRoot
{
// Tenant association
public TenantId TenantId { get; private set; } = null!;
// User identity
public Email Email { get; private set; } = null!;
public string PasswordHash { get; private set; } = string.Empty;
public FullName FullName { get; private set; } = null!;
public UserStatus Status { get; private set; }
// SSO properties
public AuthenticationProvider AuthProvider { get; private set; }
public string? ExternalUserId { get; private set; } // IdP user ID
public string? ExternalEmail { get; private set; } // Email from IdP
// Profile
public string? AvatarUrl { get; private set; }
public string? JobTitle { get; private set; }
public string? PhoneNumber { get; private set; }
// Timestamps
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
public DateTime? LastLoginAt { get; private set; }
public DateTime? EmailVerifiedAt { get; private set; }
// Security
public string? EmailVerificationToken { get; private set; }
public string? PasswordResetToken { get; private set; }
public DateTime? PasswordResetTokenExpiresAt { get; private set; }
// Private constructor for EF Core
private User() : base()
{
}
// Factory method for local authentication
public static User CreateLocal(
TenantId tenantId,
Email email,
string passwordHash,
FullName fullName)
{
var user = new User
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Email = email,
PasswordHash = passwordHash,
FullName = fullName,
Status = UserStatus.Active,
AuthProvider = AuthenticationProvider.Local,
CreatedAt = DateTime.UtcNow
};
user.AddDomainEvent(new UserCreatedEvent(user.Id, user.Email, tenantId));
return user;
}
// Factory method for SSO authentication
public static User CreateFromSso(
TenantId tenantId,
AuthenticationProvider provider,
string externalUserId,
Email email,
FullName fullName,
string? avatarUrl = null)
{
if (provider == AuthenticationProvider.Local)
throw new ArgumentException("Use CreateLocal for local authentication");
var user = new User
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Email = email,
PasswordHash = string.Empty, // No password for SSO users
FullName = fullName,
Status = UserStatus.Active,
AuthProvider = provider,
ExternalUserId = externalUserId,
ExternalEmail = email,
AvatarUrl = avatarUrl,
CreatedAt = DateTime.UtcNow,
EmailVerifiedAt = DateTime.UtcNow // Trust IdP verification
};
user.AddDomainEvent(new UserCreatedFromSsoEvent(user.Id, user.Email, tenantId, provider));
return user;
}
// Business methods
public void UpdatePassword(string newPasswordHash)
{
if (AuthProvider != AuthenticationProvider.Local)
throw new InvalidOperationException("Cannot change password for SSO users");
if (string.IsNullOrWhiteSpace(newPasswordHash))
throw new ArgumentException("Password hash cannot be empty", nameof(newPasswordHash));
PasswordHash = newPasswordHash;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new UserPasswordChangedEvent(Id));
}
public void UpdateProfile(FullName? fullName = null, string? avatarUrl = null, string? jobTitle = null, string? phoneNumber = null)
{
if (fullName is not null)
FullName = fullName;
if (avatarUrl is not null)
AvatarUrl = avatarUrl;
if (jobTitle is not null)
JobTitle = jobTitle;
if (phoneNumber is not null)
PhoneNumber = phoneNumber;
UpdatedAt = DateTime.UtcNow;
}
public void RecordLogin()
{
LastLoginAt = DateTime.UtcNow;
UpdatedAt = DateTime.UtcNow;
}
public void VerifyEmail()
{
EmailVerifiedAt = DateTime.UtcNow;
EmailVerificationToken = null;
UpdatedAt = DateTime.UtcNow;
}
public void Suspend(string reason)
{
if (Status == UserStatus.Deleted)
throw new InvalidOperationException("Cannot suspend deleted user");
Status = UserStatus.Suspended;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new UserSuspendedEvent(Id, reason));
}
public void Reactivate()
{
if (Status == UserStatus.Deleted)
throw new InvalidOperationException("Cannot reactivate deleted user");
Status = UserStatus.Active;
UpdatedAt = DateTime.UtcNow;
}
public void Delete()
{
Status = UserStatus.Deleted;
UpdatedAt = DateTime.UtcNow;
}
// SSO-specific methods
public void UpdateSsoProfile(string externalUserId, Email email, FullName fullName, string? avatarUrl = null)
{
if (AuthProvider == AuthenticationProvider.Local)
throw new InvalidOperationException("Cannot update SSO profile for local users");
ExternalUserId = externalUserId;
ExternalEmail = email;
FullName = fullName;
if (avatarUrl is not null)
AvatarUrl = avatarUrl;
UpdatedAt = DateTime.UtcNow;
}
public void SetPasswordResetToken(string token, DateTime expiresAt)
{
if (AuthProvider != AuthenticationProvider.Local)
throw new InvalidOperationException("Cannot set password reset token for SSO users");
PasswordResetToken = token;
PasswordResetTokenExpiresAt = expiresAt;
UpdatedAt = DateTime.UtcNow;
}
public void ClearPasswordResetToken()
{
PasswordResetToken = null;
PasswordResetTokenExpiresAt = null;
UpdatedAt = DateTime.UtcNow;
}
public void SetEmailVerificationToken(string token)
{
EmailVerificationToken = token;
UpdatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,33 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
public sealed class UserId : ValueObject
{
public Guid Value { get; }
private UserId(Guid value)
{
Value = value;
}
public static UserId CreateUnique() => new(Guid.NewGuid());
public static UserId Create(Guid value)
{
if (value == Guid.Empty)
throw new ArgumentException("User ID cannot be empty", nameof(value));
return new UserId(value);
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
// Implicit conversion
public static implicit operator Guid(UserId userId) => userId.Value;
}

View File

@@ -0,0 +1,8 @@
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
public enum UserStatus
{
Active = 1,
Suspended = 2,
Deleted = 3
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\Shared\ColaFlow.Shared.Kernel\ColaFlow.Shared.Kernel.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,44 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
namespace ColaFlow.Modules.Identity.Domain.Repositories;
/// <summary>
/// Repository interface for Tenant aggregate
/// </summary>
public interface ITenantRepository
{
/// <summary>
/// Get tenant by ID
/// </summary>
Task<Tenant?> GetByIdAsync(TenantId tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Get tenant by slug (unique subdomain identifier)
/// </summary>
Task<Tenant?> GetBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default);
/// <summary>
/// Check if a slug already exists
/// </summary>
Task<bool> ExistsBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default);
/// <summary>
/// Get all tenants (admin operation)
/// </summary>
Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Add a new tenant
/// </summary>
Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default);
/// <summary>
/// Update an existing tenant
/// </summary>
Task UpdateAsync(Tenant tenant, CancellationToken cancellationToken = default);
/// <summary>
/// Delete a tenant (hard delete)
/// </summary>
Task DeleteAsync(Tenant tenant, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,59 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
namespace ColaFlow.Modules.Identity.Domain.Repositories;
/// <summary>
/// Repository interface for User aggregate
/// </summary>
public interface IUserRepository
{
/// <summary>
/// Get user by ID
/// </summary>
Task<User?> GetByIdAsync(UserId userId, CancellationToken cancellationToken = default);
/// <summary>
/// Get user by email within a tenant
/// </summary>
Task<User?> GetByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default);
/// <summary>
/// Get user by external SSO ID within a tenant
/// </summary>
Task<User?> GetByExternalIdAsync(
TenantId tenantId,
AuthenticationProvider provider,
string externalUserId,
CancellationToken cancellationToken = default);
/// <summary>
/// Check if an email already exists within a tenant
/// </summary>
Task<bool> ExistsByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default);
/// <summary>
/// Get all users for a tenant
/// </summary>
Task<IReadOnlyList<User>> GetAllByTenantAsync(TenantId tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Get active users count for a tenant
/// </summary>
Task<int> GetActiveUsersCountAsync(TenantId tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Add a new user
/// </summary>
Task AddAsync(User user, CancellationToken cancellationToken = default);
/// <summary>
/// Update an existing user
/// </summary>
Task UpdateAsync(User user, CancellationToken cancellationToken = default);
/// <summary>
/// Delete a user (hard delete)
/// </summary>
Task DeleteAsync(User user, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ColaFlow.Modules.Identity.Application\ColaFlow.Modules.Identity.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,33 @@
using ColaFlow.Modules.Identity.Domain.Repositories;
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
using ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
using ColaFlow.Modules.Identity.Infrastructure.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ColaFlow.Modules.Identity.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddIdentityInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// DbContext (using connection string)
services.AddDbContext<IdentityDbContext>(options =>
options.UseNpgsql(
configuration.GetConnectionString("DefaultConnection"),
b => b.MigrationsAssembly(typeof(IdentityDbContext).Assembly.FullName)));
// Tenant Context (Scoped - one instance per request)
services.AddScoped<ITenantContext, TenantContext>();
services.AddHttpContextAccessor(); // Required for HttpContext access
// Repositories
services.AddScoped<ITenantRepository, TenantRepository>();
services.AddScoped<IUserRepository, UserRepository>();
return services;
}
}

View File

@@ -0,0 +1,96 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System.Text.Json;
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
public class TenantConfiguration : IEntityTypeConfiguration<Tenant>
{
public void Configure(EntityTypeBuilder<Tenant> builder)
{
builder.ToTable("tenants");
// Primary Key
builder.HasKey(t => t.Id);
builder.Property(t => t.Id)
.HasConversion(
id => (Guid)id,
value => TenantId.Create(value))
.HasColumnName("id");
// Value Object mappings
builder.Property(t => t.Name)
.HasConversion(
name => name.Value,
value => TenantName.Create(value))
.HasMaxLength(100)
.IsRequired()
.HasColumnName("name");
builder.Property(t => t.Slug)
.HasConversion(
slug => slug.Value,
value => TenantSlug.Create(value))
.HasMaxLength(50)
.IsRequired()
.HasColumnName("slug");
builder.HasIndex(t => t.Slug)
.IsUnique()
.HasDatabaseName("ix_tenants_slug");
// Enum mappings
builder.Property(t => t.Status)
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired()
.HasColumnName("status");
builder.Property(t => t.Plan)
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired()
.HasColumnName("plan");
// SSO Configuration (stored as JSON)
builder.Property(t => t.SsoConfig)
.HasConversion(
config => config != null ? JsonSerializer.Serialize(config, (JsonSerializerOptions?)null) : null,
json => json != null ? JsonSerializer.Deserialize<SsoConfiguration>(json, (JsonSerializerOptions?)null) : null)
.HasColumnType("jsonb")
.HasColumnName("sso_config");
// Timestamps
builder.Property(t => t.CreatedAt)
.IsRequired()
.HasColumnName("created_at");
builder.Property(t => t.UpdatedAt)
.HasColumnName("updated_at");
builder.Property(t => t.SuspendedAt)
.HasColumnName("suspended_at");
builder.Property(t => t.SuspensionReason)
.HasMaxLength(500)
.HasColumnName("suspension_reason");
// Settings
builder.Property(t => t.MaxUsers)
.IsRequired()
.HasColumnName("max_users");
builder.Property(t => t.MaxProjects)
.IsRequired()
.HasColumnName("max_projects");
builder.Property(t => t.MaxStorageGB)
.IsRequired()
.HasColumnName("max_storage_gb");
// Ignore domain events (not stored in database)
builder.Ignore(t => t.DomainEvents);
}
}

View File

@@ -0,0 +1,126 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("users");
// Primary Key
builder.HasKey(u => u.Id);
builder.Property(u => u.Id)
.HasConversion(
id => (Guid)id,
value => UserId.Create(value))
.HasColumnName("id");
// Tenant ID (foreign key)
builder.Property(u => u.TenantId)
.HasConversion(
id => (Guid)id,
value => TenantId.Create(value))
.IsRequired()
.HasColumnName("tenant_id");
// Optional: Create foreign key relationship
// builder.HasOne<Tenant>()
// .WithMany()
// .HasForeignKey(u => u.TenantId)
// .OnDelete(DeleteBehavior.Restrict);
// Value Object mappings
builder.Property(u => u.Email)
.HasConversion(
email => email.Value,
value => Email.Create(value))
.HasMaxLength(255)
.IsRequired()
.HasColumnName("email");
builder.Property(u => u.FullName)
.HasConversion(
name => name.Value,
value => FullName.Create(value))
.HasMaxLength(100)
.IsRequired()
.HasColumnName("full_name");
// Composite unique index (email unique within tenant)
builder.HasIndex(u => new { u.TenantId, u.Email })
.IsUnique()
.HasDatabaseName("ix_users_tenant_id_email");
// Enum mappings
builder.Property(u => u.Status)
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired()
.HasColumnName("status");
builder.Property(u => u.AuthProvider)
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired()
.HasColumnName("auth_provider");
// Nullable fields
builder.Property(u => u.PasswordHash)
.HasMaxLength(255)
.HasColumnName("password_hash");
builder.Property(u => u.ExternalUserId)
.HasMaxLength(255)
.HasColumnName("external_user_id");
builder.Property(u => u.ExternalEmail)
.HasMaxLength(255)
.HasColumnName("external_email");
builder.Property(u => u.AvatarUrl)
.HasMaxLength(500)
.HasColumnName("avatar_url");
builder.Property(u => u.JobTitle)
.HasMaxLength(100)
.HasColumnName("job_title");
builder.Property(u => u.PhoneNumber)
.HasMaxLength(50)
.HasColumnName("phone_number");
// Timestamps
builder.Property(u => u.CreatedAt)
.IsRequired()
.HasColumnName("created_at");
builder.Property(u => u.UpdatedAt)
.HasColumnName("updated_at");
builder.Property(u => u.LastLoginAt)
.HasColumnName("last_login_at");
builder.Property(u => u.EmailVerifiedAt)
.HasColumnName("email_verified_at");
// Security tokens
builder.Property(u => u.EmailVerificationToken)
.HasMaxLength(255)
.HasColumnName("email_verification_token");
builder.Property(u => u.PasswordResetToken)
.HasMaxLength(255)
.HasColumnName("password_reset_token");
builder.Property(u => u.PasswordResetTokenExpiresAt)
.HasColumnName("password_reset_token_expires_at");
// Ignore domain events
builder.Ignore(u => u.DomainEvents);
}
}

View File

@@ -0,0 +1,51 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Infrastructure.Services;
using Microsoft.EntityFrameworkCore;
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence;
public class IdentityDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
public IdentityDbContext(
DbContextOptions<IdentityDbContext> options,
ITenantContext tenantContext)
: base(options)
{
_tenantContext = tenantContext;
}
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply all configurations from assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(IdentityDbContext).Assembly);
// Configure Global Query Filters (automatic tenant data filtering)
ConfigureGlobalQueryFilters(modelBuilder);
}
private void ConfigureGlobalQueryFilters(ModelBuilder modelBuilder)
{
// User entity global query filter
// Automatically adds: WHERE tenant_id = @current_tenant_id
modelBuilder.Entity<User>().HasQueryFilter(u =>
!_tenantContext.IsSet || u.TenantId == _tenantContext.TenantId);
// Tenant entity doesn't need filter (need to query all tenants)
}
/// <summary>
/// Disable Query Filter (for admin operations)
/// </summary>
public IQueryable<T> WithoutTenantFilter<T>() where T : class
{
return Set<T>().IgnoreQueryFilters();
}
}

View File

@@ -0,0 +1,208 @@
// <auto-generated />
using System;
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(IdentityDbContext))]
[Migration("20251103125512_InitialIdentityModule")]
partial class InitialIdentityModule
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("MaxProjects")
.HasColumnType("integer")
.HasColumnName("max_projects");
b.Property<int>("MaxStorageGB")
.HasColumnType("integer")
.HasColumnName("max_storage_gb");
b.Property<int>("MaxUsers")
.HasColumnType("integer")
.HasColumnName("max_users");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("plan");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("slug");
b.Property<string>("SsoConfig")
.HasColumnType("jsonb")
.HasColumnName("sso_config");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("status");
b.Property<DateTime?>("SuspendedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("suspended_at");
b.Property<string>("SuspensionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("suspension_reason");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_tenants_slug");
b.ToTable("tenants", (string)null);
});
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("AuthProvider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("auth_provider");
b.Property<string>("AvatarUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("avatar_url");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("email");
b.Property<string>("EmailVerificationToken")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("email_verification_token");
b.Property<DateTime?>("EmailVerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("email_verified_at");
b.Property<string>("ExternalEmail")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("external_email");
b.Property<string>("ExternalUserId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("external_user_id");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("full_name");
b.Property<string>("JobTitle")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("job_title");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_login_at");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("password_hash");
b.Property<string>("PasswordResetToken")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("password_reset_token");
b.Property<DateTime?>("PasswordResetTokenExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("password_reset_token_expires_at");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("phone_number");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("TenantId", "Email")
.IsUnique()
.HasDatabaseName("ix_users_tenant_id_email");
b.ToTable("users", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,89 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialIdentityModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "tenants",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
slug = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
plan = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
sso_config = table.Column<string>(type: "jsonb", nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
suspended_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
suspension_reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
max_users = table.Column<int>(type: "integer", nullable: false),
max_projects = table.Column<int>(type: "integer", nullable: false),
max_storage_gb = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_tenants", x => x.id);
});
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
email = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
password_hash = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
full_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
auth_provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
external_user_id = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
external_email = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
avatar_url = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
job_title = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
phone_number = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
last_login_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
email_verified_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
email_verification_token = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
password_reset_token = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
password_reset_token_expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_users", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_tenants_slug",
table: "tenants",
column: "slug",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_users_tenant_id_email",
table: "users",
columns: new[] { "tenant_id", "email" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "tenants");
migrationBuilder.DropTable(
name: "users");
}
}
}

View File

@@ -0,0 +1,205 @@
// <auto-generated />
using System;
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(IdentityDbContext))]
partial class IdentityDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("MaxProjects")
.HasColumnType("integer")
.HasColumnName("max_projects");
b.Property<int>("MaxStorageGB")
.HasColumnType("integer")
.HasColumnName("max_storage_gb");
b.Property<int>("MaxUsers")
.HasColumnType("integer")
.HasColumnName("max_users");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("plan");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("slug");
b.Property<string>("SsoConfig")
.HasColumnType("jsonb")
.HasColumnName("sso_config");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("status");
b.Property<DateTime?>("SuspendedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("suspended_at");
b.Property<string>("SuspensionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("suspension_reason");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_tenants_slug");
b.ToTable("tenants", (string)null);
});
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("AuthProvider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("auth_provider");
b.Property<string>("AvatarUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("avatar_url");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("email");
b.Property<string>("EmailVerificationToken")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("email_verification_token");
b.Property<DateTime?>("EmailVerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("email_verified_at");
b.Property<string>("ExternalEmail")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("external_email");
b.Property<string>("ExternalUserId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("external_user_id");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("full_name");
b.Property<string>("JobTitle")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("job_title");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_login_at");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("password_hash");
b.Property<string>("PasswordResetToken")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("password_reset_token");
b.Property<DateTime?>("PasswordResetTokenExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("password_reset_token_expires_at");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("phone_number");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("TenantId", "Email")
.IsUnique()
.HasDatabaseName("ix_users_tenant_id_email");
b.ToTable("users", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,56 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
public class TenantRepository : ITenantRepository
{
private readonly IdentityDbContext _context;
public TenantRepository(IdentityDbContext context)
{
_context = context;
}
public async Task<Tenant?> GetByIdAsync(TenantId tenantId, CancellationToken cancellationToken = default)
{
return await _context.Tenants
.FirstOrDefaultAsync(t => t.Id == tenantId, cancellationToken);
}
public async Task<Tenant?> GetBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default)
{
return await _context.Tenants
.FirstOrDefaultAsync(t => t.Slug == slug, cancellationToken);
}
public async Task<bool> ExistsBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default)
{
return await _context.Tenants
.AnyAsync(t => t.Slug == slug, cancellationToken);
}
public async Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.Tenants.ToListAsync(cancellationToken);
}
public async Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default)
{
await _context.Tenants.AddAsync(tenant, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task UpdateAsync(Tenant tenant, CancellationToken cancellationToken = default)
{
_context.Tenants.Update(tenant);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(Tenant tenant, CancellationToken cancellationToken = default)
{
_context.Tenants.Remove(tenant);
await _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,80 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
public class UserRepository : IUserRepository
{
private readonly IdentityDbContext _context;
public UserRepository(IdentityDbContext context)
{
_context = context;
}
public async Task<User?> GetByIdAsync(UserId userId, CancellationToken cancellationToken = default)
{
// Global Query Filter automatically applies
return await _context.Users
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
}
public async Task<User?> GetByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken);
}
public async Task<User?> GetByExternalIdAsync(
TenantId tenantId,
AuthenticationProvider provider,
string externalUserId,
CancellationToken cancellationToken = default)
{
return await _context.Users
.FirstOrDefaultAsync(
u => u.TenantId == tenantId &&
u.AuthProvider == provider &&
u.ExternalUserId == externalUserId,
cancellationToken);
}
public async Task<bool> ExistsByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default)
{
return await _context.Users
.AnyAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken);
}
public async Task<IReadOnlyList<User>> GetAllByTenantAsync(TenantId tenantId, CancellationToken cancellationToken = default)
{
return await _context.Users
.Where(u => u.TenantId == tenantId)
.ToListAsync(cancellationToken);
}
public async Task<int> GetActiveUsersCountAsync(TenantId tenantId, CancellationToken cancellationToken = default)
{
return await _context.Users
.CountAsync(u => u.TenantId == tenantId && u.Status == UserStatus.Active, cancellationToken);
}
public async Task AddAsync(User user, CancellationToken cancellationToken = default)
{
await _context.Users.AddAsync(user, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task UpdateAsync(User user, CancellationToken cancellationToken = default)
{
_context.Users.Update(user);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(User user, CancellationToken cancellationToken = default)
{
_context.Users.Remove(user);
await _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,34 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
/// <summary>
/// Tenant context interface - provides current request tenant information
/// </summary>
public interface ITenantContext
{
/// <summary>
/// Current tenant ID
/// </summary>
TenantId? TenantId { get; }
/// <summary>
/// Current tenant slug
/// </summary>
string? TenantSlug { get; }
/// <summary>
/// Whether tenant is set
/// </summary>
bool IsSet { get; }
/// <summary>
/// Set current tenant
/// </summary>
void SetTenant(TenantId tenantId, string tenantSlug);
/// <summary>
/// Clear tenant information
/// </summary>
void Clear();
}

View File

@@ -0,0 +1,56 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using Microsoft.AspNetCore.Http;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
/// <summary>
/// Tenant context implementation (Scoped lifetime - one instance per request)
/// </summary>
public class TenantContext : ITenantContext
{
private readonly IHttpContextAccessor _httpContextAccessor;
private TenantId? _tenantId;
private string? _tenantSlug;
public TenantContext(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
InitializeFromHttpContext();
}
public TenantId? TenantId => _tenantId;
public string? TenantSlug => _tenantSlug;
public bool IsSet => _tenantId != null;
public void SetTenant(TenantId tenantId, string tenantSlug)
{
_tenantId = tenantId;
_tenantSlug = tenantSlug;
}
public void Clear()
{
_tenantId = null;
_tenantSlug = null;
}
/// <summary>
/// Initialize from HTTP Context (extract tenant info from JWT Claims)
/// </summary>
private void InitializeFromHttpContext()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
// Extract tenant_id from JWT Claims
var tenantIdClaim = httpContext.User.FindFirst("tenant_id");
var tenantSlugClaim = httpContext.User.FindFirst("tenant_slug");
if (tenantIdClaim != null && Guid.TryParse(tenantIdClaim.Value, out var tenantIdGuid))
{
_tenantId = TenantId.Create(tenantIdGuid);
_tenantSlug = tenantSlugClaim?.Value;
}
}
}
}

View File

@@ -0,0 +1,210 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
using FluentAssertions;
using Xunit;
namespace ColaFlow.Modules.Identity.Domain.Tests.Aggregates;
public sealed class TenantTests
{
[Fact]
public void Create_ShouldSucceed()
{
// Arrange
var name = TenantName.Create("Acme Corporation");
var slug = TenantSlug.Create("acme");
// Act
var tenant = Tenant.Create(name, slug);
// Assert
tenant.Should().NotBeNull();
tenant.Id.Should().NotBe(Guid.Empty);
tenant.Name.Value.Should().Be("Acme Corporation");
tenant.Slug.Value.Should().Be("acme");
tenant.Status.Should().Be(TenantStatus.Active);
tenant.Plan.Should().Be(SubscriptionPlan.Free);
tenant.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public void Create_ShouldRaiseTenantCreatedEvent()
{
// Arrange
var name = TenantName.Create("Acme Corporation");
var slug = TenantSlug.Create("acme");
// Act
var tenant = Tenant.Create(name, slug);
// Assert
tenant.DomainEvents.Should().ContainSingle();
tenant.DomainEvents.Should().ContainItemsAssignableTo<TenantCreatedEvent>();
var domainEvent = tenant.DomainEvents.First() as TenantCreatedEvent;
domainEvent.Should().NotBeNull();
domainEvent!.TenantId.Should().Be(tenant.Id);
domainEvent.Slug.Should().Be("acme");
}
[Fact]
public void Activate_ShouldChangeStatus()
{
// Arrange
var tenant = Tenant.Create(TenantName.Create("Test"), TenantSlug.Create("test"));
tenant.Suspend("Payment failed");
tenant.ClearDomainEvents();
// Act
tenant.Activate();
// Assert
tenant.Status.Should().Be(TenantStatus.Active);
tenant.SuspendedAt.Should().BeNull();
tenant.SuspensionReason.Should().BeNull();
tenant.DomainEvents.Should().ContainSingle();
tenant.DomainEvents.Should().ContainItemsAssignableTo<TenantActivatedEvent>();
}
[Fact]
public void Suspend_ShouldChangeStatus()
{
// Arrange
var tenant = Tenant.Create(TenantName.Create("Test"), TenantSlug.Create("test"));
tenant.ClearDomainEvents();
// Act
tenant.Suspend("Payment failed");
// Assert
tenant.Status.Should().Be(TenantStatus.Suspended);
tenant.SuspendedAt.Should().NotBeNull();
tenant.SuspendedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
tenant.SuspensionReason.Should().Be("Payment failed");
tenant.DomainEvents.Should().ContainSingle();
tenant.DomainEvents.Should().ContainItemsAssignableTo<TenantSuspendedEvent>();
}
[Fact]
public void Cancel_ShouldChangeStatus()
{
// Arrange
var tenant = Tenant.Create(TenantName.Create("Test"), TenantSlug.Create("test"));
tenant.ClearDomainEvents();
// Act
tenant.Cancel();
// Assert
tenant.Status.Should().Be(TenantStatus.Cancelled);
tenant.DomainEvents.Should().ContainSingle();
tenant.DomainEvents.Should().ContainItemsAssignableTo<TenantCancelledEvent>();
}
[Fact]
public void CancelledTenant_CannotBeActivated()
{
// Arrange
var tenant = Tenant.Create(TenantName.Create("Test"), TenantSlug.Create("test"));
tenant.Cancel();
// Act & Assert
var act = () => tenant.Activate();
act.Should().Throw<InvalidOperationException>()
.WithMessage("Cannot activate cancelled tenant");
}
[Fact]
public void ConfigureSso_ShouldSucceed_ForProfessionalPlan()
{
// Arrange
var tenant = Tenant.Create(TenantName.Create("Test"), TenantSlug.Create("test"));
tenant.UpgradePlan(SubscriptionPlan.Professional);
tenant.ClearDomainEvents();
var ssoConfig = SsoConfiguration.CreateOidc(
SsoProvider.AzureAD,
"https://login.microsoftonline.com/tenant-id",
"client-id",
"client-secret");
// Act
tenant.ConfigureSso(ssoConfig);
// Assert
tenant.SsoConfig.Should().NotBeNull();
tenant.SsoConfig!.Provider.Should().Be(SsoProvider.AzureAD);
tenant.DomainEvents.Should().ContainSingle();
tenant.DomainEvents.Should().ContainItemsAssignableTo<SsoConfiguredEvent>();
}
[Fact]
public void ConfigureSso_ShouldThrow_ForFreePlan()
{
// Arrange
var tenant = Tenant.Create(TenantName.Create("Test"), TenantSlug.Create("test"));
var ssoConfig = SsoConfiguration.CreateOidc(
SsoProvider.AzureAD,
"https://login.microsoftonline.com/tenant-id",
"client-id",
"client-secret");
// Act & Assert
var act = () => tenant.ConfigureSso(ssoConfig);
act.Should().Throw<InvalidOperationException>()
.WithMessage("SSO is only available for Professional and Enterprise plans");
}
[Fact]
public void UpgradePlan_ShouldIncreaseResourceLimits()
{
// Arrange
var tenant = Tenant.Create(TenantName.Create("Test"), TenantSlug.Create("test"));
tenant.ClearDomainEvents();
// Act
tenant.UpgradePlan(SubscriptionPlan.Professional);
// Assert
tenant.Plan.Should().Be(SubscriptionPlan.Professional);
tenant.MaxUsers.Should().Be(100);
tenant.MaxProjects.Should().Be(100);
tenant.MaxStorageGB.Should().Be(100);
tenant.DomainEvents.Should().ContainSingle();
tenant.DomainEvents.Should().ContainItemsAssignableTo<TenantPlanUpgradedEvent>();
}
[Fact]
public void UpgradePlan_ShouldThrow_WhenDowngrading()
{
// Arrange
var tenant = Tenant.Create(TenantName.Create("Test"), TenantSlug.Create("test"), SubscriptionPlan.Professional);
// Act & Assert
var act = () => tenant.UpgradePlan(SubscriptionPlan.Free);
act.Should().Throw<InvalidOperationException>()
.WithMessage("New plan must be higher than current plan");
}
[Fact]
public void DisableSso_ShouldSucceed()
{
// Arrange
var tenant = Tenant.Create(TenantName.Create("Test"), TenantSlug.Create("test"));
tenant.UpgradePlan(SubscriptionPlan.Enterprise);
var ssoConfig = SsoConfiguration.CreateOidc(
SsoProvider.Google,
"https://accounts.google.com",
"client-id",
"client-secret");
tenant.ConfigureSso(ssoConfig);
tenant.ClearDomainEvents();
// Act
tenant.DisableSso();
// Assert
tenant.SsoConfig.Should().BeNull();
tenant.DomainEvents.Should().ContainSingle();
tenant.DomainEvents.Should().ContainItemsAssignableTo<SsoDisabledEvent>();
}
}

View File

@@ -0,0 +1,305 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
using FluentAssertions;
using Xunit;
namespace ColaFlow.Modules.Identity.Domain.Tests.Aggregates;
public sealed class UserTests
{
private readonly TenantId _tenantId = TenantId.CreateUnique();
[Fact]
public void CreateLocal_ShouldSucceed()
{
// Arrange
var email = Email.Create("test@example.com");
var fullName = FullName.Create("John Doe");
var passwordHash = "hashed_password";
// Act
var user = User.CreateLocal(_tenantId, email, passwordHash, fullName);
// Assert
user.Should().NotBeNull();
user.Id.Should().NotBe(Guid.Empty);
user.TenantId.Should().Be(_tenantId);
user.Email.Value.Should().Be("test@example.com");
user.FullName.Value.Should().Be("John Doe");
user.PasswordHash.Should().Be(passwordHash);
user.Status.Should().Be(UserStatus.Active);
user.AuthProvider.Should().Be(AuthenticationProvider.Local);
user.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public void CreateLocal_ShouldRaiseUserCreatedEvent()
{
// Arrange
var email = Email.Create("test@example.com");
var fullName = FullName.Create("John Doe");
// Act
var user = User.CreateLocal(_tenantId, email, "password", fullName);
// Assert
user.DomainEvents.Should().ContainSingle();
user.DomainEvents.Should().ContainItemsAssignableTo<UserCreatedEvent>();
var domainEvent = user.DomainEvents.First() as UserCreatedEvent;
domainEvent.Should().NotBeNull();
domainEvent!.UserId.Should().Be(user.Id);
domainEvent.Email.Should().Be("test@example.com");
domainEvent.TenantId.Should().Be(_tenantId);
}
[Fact]
public void CreateFromSso_ShouldSucceed()
{
// Arrange
var email = Email.Create("user@company.com");
var fullName = FullName.Create("Jane Smith");
var externalUserId = "google-12345";
var avatarUrl = "https://example.com/avatar.jpg";
// Act
var user = User.CreateFromSso(
_tenantId,
AuthenticationProvider.Google,
externalUserId,
email,
fullName,
avatarUrl);
// Assert
user.Should().NotBeNull();
user.TenantId.Should().Be(_tenantId);
user.AuthProvider.Should().Be(AuthenticationProvider.Google);
user.ExternalUserId.Should().Be(externalUserId);
user.ExternalEmail.Should().Be("user@company.com");
user.AvatarUrl.Should().Be(avatarUrl);
user.PasswordHash.Should().BeEmpty();
user.EmailVerifiedAt.Should().NotBeNull(); // SSO users are auto-verified
}
[Fact]
public void CreateFromSso_ShouldRaiseUserCreatedFromSsoEvent()
{
// Arrange
var email = Email.Create("user@company.com");
var fullName = FullName.Create("Jane Smith");
// Act
var user = User.CreateFromSso(
_tenantId,
AuthenticationProvider.AzureAD,
"azure-123",
email,
fullName);
// Assert
user.DomainEvents.Should().ContainSingle();
user.DomainEvents.Should().ContainItemsAssignableTo<UserCreatedFromSsoEvent>();
var domainEvent = user.DomainEvents.First() as UserCreatedFromSsoEvent;
domainEvent.Should().NotBeNull();
domainEvent!.Provider.Should().Be(AuthenticationProvider.AzureAD);
}
[Fact]
public void CreateFromSso_ShouldThrow_ForLocalProvider()
{
// Arrange
var email = Email.Create("test@example.com");
var fullName = FullName.Create("John Doe");
// Act & Assert
var act = () => User.CreateFromSso(
_tenantId,
AuthenticationProvider.Local,
"external-id",
email,
fullName);
act.Should().Throw<ArgumentException>()
.WithMessage("Use CreateLocal for local authentication");
}
[Fact]
public void UpdatePassword_ShouldSucceed_ForLocalUser()
{
// Arrange
var user = User.CreateLocal(
_tenantId,
Email.Create("test@example.com"),
"old_hash",
FullName.Create("John Doe"));
user.ClearDomainEvents();
// Act
user.UpdatePassword("new_hash");
// Assert
user.PasswordHash.Should().Be("new_hash");
user.DomainEvents.Should().ContainSingle();
user.DomainEvents.Should().ContainItemsAssignableTo<UserPasswordChangedEvent>();
}
[Fact]
public void UpdatePassword_ShouldThrow_ForSsoUser()
{
// Arrange
var user = User.CreateFromSso(
_tenantId,
AuthenticationProvider.Google,
"google-123",
Email.Create("test@example.com"),
FullName.Create("John Doe"));
// Act & Assert
var act = () => user.UpdatePassword("new_hash");
act.Should().Throw<InvalidOperationException>()
.WithMessage("Cannot change password for SSO users");
}
[Fact]
public void UpdateProfile_ShouldUpdateAllFields()
{
// Arrange
var user = User.CreateLocal(
_tenantId,
Email.Create("test@example.com"),
"hash",
FullName.Create("John Doe"));
// Act
user.UpdateProfile(
fullName: FullName.Create("Jane Smith"),
avatarUrl: "https://example.com/avatar.jpg",
jobTitle: "Software Engineer",
phoneNumber: "+1234567890");
// Assert
user.FullName.Value.Should().Be("Jane Smith");
user.AvatarUrl.Should().Be("https://example.com/avatar.jpg");
user.JobTitle.Should().Be("Software Engineer");
user.PhoneNumber.Should().Be("+1234567890");
}
[Fact]
public void RecordLogin_ShouldUpdateLastLoginAt()
{
// Arrange
var user = User.CreateLocal(
_tenantId,
Email.Create("test@example.com"),
"hash",
FullName.Create("John Doe"));
// Act
user.RecordLogin();
// Assert
user.LastLoginAt.Should().NotBeNull();
user.LastLoginAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public void VerifyEmail_ShouldSetEmailVerifiedAt()
{
// Arrange
var user = User.CreateLocal(
_tenantId,
Email.Create("test@example.com"),
"hash",
FullName.Create("John Doe"));
// Act
user.VerifyEmail();
// Assert
user.EmailVerifiedAt.Should().NotBeNull();
user.EmailVerifiedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
user.EmailVerificationToken.Should().BeNull();
}
[Fact]
public void Suspend_ShouldChangeStatus()
{
// Arrange
var user = User.CreateLocal(
_tenantId,
Email.Create("test@example.com"),
"hash",
FullName.Create("John Doe"));
user.ClearDomainEvents();
// Act
user.Suspend("Violation of terms");
// Assert
user.Status.Should().Be(UserStatus.Suspended);
user.DomainEvents.Should().ContainSingle();
user.DomainEvents.Should().ContainItemsAssignableTo<UserSuspendedEvent>();
}
[Fact]
public void Reactivate_ShouldChangeStatusToActive()
{
// Arrange
var user = User.CreateLocal(
_tenantId,
Email.Create("test@example.com"),
"hash",
FullName.Create("John Doe"));
user.Suspend("Test");
// Act
user.Reactivate();
// Assert
user.Status.Should().Be(UserStatus.Active);
}
[Fact]
public void UpdateSsoProfile_ShouldSucceed_ForSsoUser()
{
// Arrange
var user = User.CreateFromSso(
_tenantId,
AuthenticationProvider.Google,
"google-123",
Email.Create("old@example.com"),
FullName.Create("Old Name"));
// Act
user.UpdateSsoProfile(
"google-456",
Email.Create("new@example.com"),
FullName.Create("New Name"),
"https://new-avatar.jpg");
// Assert
user.ExternalUserId.Should().Be("google-456");
user.ExternalEmail.Should().Be("new@example.com");
user.FullName.Value.Should().Be("New Name");
user.AvatarUrl.Should().Be("https://new-avatar.jpg");
}
[Fact]
public void UpdateSsoProfile_ShouldThrow_ForLocalUser()
{
// Arrange
var user = User.CreateLocal(
_tenantId,
Email.Create("test@example.com"),
"hash",
FullName.Create("John Doe"));
// Act & Assert
var act = () => user.UpdateSsoProfile(
"external-id",
Email.Create("new@example.com"),
FullName.Create("New Name"));
act.Should().Throw<InvalidOperationException>()
.WithMessage("Cannot update SSO profile for local users");
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Modules\Identity\ColaFlow.Modules.Identity.Domain\ColaFlow.Modules.Identity.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace ColaFlow.Modules.Identity.Domain.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,73 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using FluentAssertions;
using Xunit;
namespace ColaFlow.Modules.Identity.Domain.Tests.ValueObjects;
public sealed class TenantSlugTests
{
[Theory]
[InlineData("acme")]
[InlineData("beta-corp")]
[InlineData("test-123")]
[InlineData("abc")]
public void Create_ShouldSucceed_ForValidSlug(string slug)
{
// Act
var tenantSlug = TenantSlug.Create(slug);
// Assert
tenantSlug.Value.Should().Be(slug);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("ab")] // Too short
[InlineData("www")] // Reserved
[InlineData("api")] // Reserved
[InlineData("admin")] // Reserved
[InlineData("acme_corp")] // Underscore
[InlineData("acme corp")] // Space
[InlineData("-acme")] // Starts with hyphen
[InlineData("acme-")] // Ends with hyphen
[InlineData("acme--corp")] // Double hyphen
public void Create_ShouldThrow_ForInvalidSlug(string slug)
{
// Act & Assert
var act = () => TenantSlug.Create(slug);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Create_ShouldConvertToLowerCase()
{
// Act
var tenantSlug = TenantSlug.Create("AcmeCorp");
// Assert
tenantSlug.Value.Should().Be("acmecorp");
}
[Fact]
public void Create_ShouldTrimWhitespace()
{
// Act
var tenantSlug = TenantSlug.Create(" acme ");
// Assert
tenantSlug.Value.Should().Be("acme");
}
[Fact]
public void Create_ShouldThrow_ForTooLongSlug()
{
// Arrange
var longSlug = new string('a', 51);
// Act & Assert
var act = () => TenantSlug.Create(longSlug);
act.Should().Throw<ArgumentException>()
.WithMessage("*cannot exceed 50 characters*");
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Modules\Identity\ColaFlow.Modules.Identity.Infrastructure\ColaFlow.Modules.Identity.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,171 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
using ColaFlow.Modules.Identity.Infrastructure.Services;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Moq;
namespace ColaFlow.Modules.Identity.Infrastructure.Tests.Persistence;
public class GlobalQueryFilterTests : IDisposable
{
private readonly Mock<ITenantContext> _mockTenantContext;
private readonly IdentityDbContext _context;
public GlobalQueryFilterTests()
{
var options = new DbContextOptionsBuilder<IdentityDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_mockTenantContext = new Mock<ITenantContext>();
_context = new IdentityDbContext(options, _mockTenantContext.Object);
}
[Fact]
public async Task GlobalQueryFilter_ShouldFilterByTenant()
{
// Arrange - Create 2 users from different tenants
var tenant1Id = TenantId.CreateUnique();
var tenant2Id = TenantId.CreateUnique();
// Setup mock to filter for tenant1
var mockTenantContext = new Mock<ITenantContext>();
mockTenantContext.Setup(x => x.IsSet).Returns(true);
mockTenantContext.Setup(x => x.TenantId).Returns(tenant1Id);
var options = new DbContextOptionsBuilder<IdentityDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new IdentityDbContext(options, mockTenantContext.Object);
var user1 = User.CreateLocal(
tenant1Id,
Email.Create("user1@tenant1.com"),
"password123",
FullName.Create("User One"));
var user2 = User.CreateLocal(
tenant2Id,
Email.Create("user2@tenant2.com"),
"password123",
FullName.Create("User Two"));
await context.Users.AddAsync(user1);
await context.Users.AddAsync(user2);
await context.SaveChangesAsync();
// Act - Query users (should be filtered by tenant1)
var filteredUsers = await context.Users.ToListAsync();
// Assert - Should only return tenant1's user
filteredUsers.Should().HaveCount(1);
filteredUsers[0].Email.Value.Should().Be("user1@tenant1.com");
}
[Fact]
public async Task WithoutTenantFilter_ShouldReturnAllUsers()
{
// Arrange - Create 2 users from different tenants
var tenant1Id = TenantId.CreateUnique();
var tenant2Id = TenantId.CreateUnique();
// Setup mock to filter for tenant1
var mockTenantContext = new Mock<ITenantContext>();
mockTenantContext.Setup(x => x.IsSet).Returns(true);
mockTenantContext.Setup(x => x.TenantId).Returns(tenant1Id);
var options = new DbContextOptionsBuilder<IdentityDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new IdentityDbContext(options, mockTenantContext.Object);
var user1 = User.CreateLocal(tenant1Id, Email.Create("admin@tenant1.com"), "pass", FullName.Create("Admin One"));
var user2 = User.CreateLocal(tenant2Id, Email.Create("admin@tenant2.com"), "pass", FullName.Create("Admin Two"));
await context.Users.AddAsync(user1);
await context.Users.AddAsync(user2);
await context.SaveChangesAsync();
// Act - Use WithoutTenantFilter to bypass filter
var allUsers = await context.WithoutTenantFilter<User>().ToListAsync();
// Assert - Should return all users
allUsers.Should().HaveCount(2);
}
[Fact]
public async Task GlobalQueryFilter_ShouldNotFilter_WhenTenantContextNotSet()
{
// Arrange - Tenant context not set
var tenant1Id = TenantId.CreateUnique();
var tenant2Id = TenantId.CreateUnique();
var mockTenantContext = new Mock<ITenantContext>();
mockTenantContext.Setup(x => x.IsSet).Returns(false); // NOT set
var options = new DbContextOptionsBuilder<IdentityDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new IdentityDbContext(options, mockTenantContext.Object);
var user1 = User.CreateLocal(tenant1Id, Email.Create("user1@test.com"), "pass", FullName.Create("User One"));
var user2 = User.CreateLocal(tenant2Id, Email.Create("user2@test.com"), "pass", FullName.Create("User Two"));
await context.Users.AddAsync(user1);
await context.Users.AddAsync(user2);
await context.SaveChangesAsync();
// Act - Query without tenant filter (because IsSet = false)
var allUsers = await context.Users.ToListAsync();
// Assert - Should return all users (no filtering)
allUsers.Should().HaveCount(2);
}
[Fact]
public async Task UserRepository_ShouldRespectTenantFilter()
{
// Arrange
var tenant1Id = TenantId.CreateUnique();
var tenant2Id = TenantId.CreateUnique();
var mockTenantContext = new Mock<ITenantContext>();
mockTenantContext.Setup(x => x.IsSet).Returns(true);
mockTenantContext.Setup(x => x.TenantId).Returns(tenant1Id);
var options = new DbContextOptionsBuilder<IdentityDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new IdentityDbContext(options, mockTenantContext.Object);
var user1 = User.CreateLocal(tenant1Id, Email.Create("john@tenant1.com"), "pass", FullName.Create("John Doe"));
var user2 = User.CreateLocal(tenant2Id, Email.Create("jane@tenant2.com"), "pass", FullName.Create("Jane Doe"));
await context.Users.AddAsync(user1);
await context.Users.AddAsync(user2);
await context.SaveChangesAsync();
// Act
var repository = new ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories.UserRepository(context);
var retrievedUser = await repository.GetByIdAsync(UserId.Create(user1.Id));
// Assert - Should find user1 (same tenant)
retrievedUser.Should().NotBeNull();
retrievedUser!.Email.Value.Should().Be("john@tenant1.com");
// Trying to get user2 (from different tenant) should return null due to filter
var user2Attempt = await repository.GetByIdAsync(UserId.Create(user2.Id));
user2Attempt.Should().BeNull();
}
public void Dispose()
{
_context.Dispose();
}
}

View File

@@ -0,0 +1,160 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
using ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
using ColaFlow.Modules.Identity.Infrastructure.Services;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Moq;
namespace ColaFlow.Modules.Identity.Infrastructure.Tests.Repositories;
public class TenantRepositoryTests : IDisposable
{
private readonly IdentityDbContext _context;
private readonly TenantRepository _repository;
public TenantRepositoryTests()
{
var options = new DbContextOptionsBuilder<IdentityDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var mockTenantContext = new Mock<ITenantContext>();
mockTenantContext.Setup(x => x.IsSet).Returns(false);
_context = new IdentityDbContext(options, mockTenantContext.Object);
_repository = new TenantRepository(_context);
}
[Fact]
public async Task AddAsync_ShouldPersistTenant()
{
// Arrange
var tenant = Tenant.Create(
TenantName.Create("Test Company"),
TenantSlug.Create("test-company"),
SubscriptionPlan.Professional);
// Act
await _repository.AddAsync(tenant);
// Assert
var retrieved = await _repository.GetByIdAsync(TenantId.Create(tenant.Id));
retrieved.Should().NotBeNull();
retrieved!.Name.Value.Should().Be("Test Company");
retrieved.Slug.Value.Should().Be("test-company");
retrieved.Plan.Should().Be(SubscriptionPlan.Professional);
}
[Fact]
public async Task GetBySlugAsync_ShouldReturnTenant()
{
// Arrange
var slug = TenantSlug.Create("acme-corp");
var tenant = Tenant.Create(
TenantName.Create("Acme Corp"),
slug,
SubscriptionPlan.Enterprise);
await _repository.AddAsync(tenant);
// Act
var retrieved = await _repository.GetBySlugAsync(slug);
// Assert
retrieved.Should().NotBeNull();
retrieved!.Slug.Value.Should().Be("acme-corp");
retrieved.Name.Value.Should().Be("Acme Corp");
}
[Fact]
public async Task ExistsBySlugAsync_ShouldReturnTrue_WhenSlugExists()
{
// Arrange
var slug = TenantSlug.Create("unique-slug");
var tenant = Tenant.Create(
TenantName.Create("Unique Company"),
slug,
SubscriptionPlan.Free);
await _repository.AddAsync(tenant);
// Act
var exists = await _repository.ExistsBySlugAsync(slug);
// Assert
exists.Should().BeTrue();
}
[Fact]
public async Task ExistsBySlugAsync_ShouldReturnFalse_WhenSlugDoesNotExist()
{
// Arrange
var slug = TenantSlug.Create("non-existent");
// Act
var exists = await _repository.ExistsBySlugAsync(slug);
// Assert
exists.Should().BeFalse();
}
[Fact]
public async Task UpdateAsync_ShouldModifyTenant()
{
// Arrange
var tenant = Tenant.Create(
TenantName.Create("Original Name"),
TenantSlug.Create("original-slug"),
SubscriptionPlan.Free);
await _repository.AddAsync(tenant);
// Act
tenant.UpdateName(TenantName.Create("Updated Name"));
await _repository.UpdateAsync(tenant);
// Assert
var retrieved = await _repository.GetByIdAsync(TenantId.Create(tenant.Id));
retrieved.Should().NotBeNull();
retrieved!.Name.Value.Should().Be("Updated Name");
}
[Fact]
public async Task GetAllAsync_ShouldReturnAllTenants()
{
// Arrange
var tenant1 = Tenant.Create(TenantName.Create("Tenant 1"), TenantSlug.Create("tenant-1"), SubscriptionPlan.Free);
var tenant2 = Tenant.Create(TenantName.Create("Tenant 2"), TenantSlug.Create("tenant-2"), SubscriptionPlan.Starter);
await _repository.AddAsync(tenant1);
await _repository.AddAsync(tenant2);
// Act
var allTenants = await _repository.GetAllAsync();
// Assert
allTenants.Should().HaveCount(2);
allTenants.Should().Contain(t => t.Name.Value == "Tenant 1");
allTenants.Should().Contain(t => t.Name.Value == "Tenant 2");
}
[Fact]
public async Task DeleteAsync_ShouldRemoveTenant()
{
// Arrange
var tenant = Tenant.Create(
TenantName.Create("To Delete"),
TenantSlug.Create("to-delete"),
SubscriptionPlan.Free);
await _repository.AddAsync(tenant);
// Act
await _repository.DeleteAsync(tenant);
// Assert
var retrieved = await _repository.GetByIdAsync(TenantId.Create(tenant.Id));
retrieved.Should().BeNull();
}
public void Dispose()
{
_context.Dispose();
}
}

View File

@@ -0,0 +1,10 @@
namespace ColaFlow.Modules.Identity.Infrastructure.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}