feat(api): UDT-004 controller de roles + refactor validator UDT-003 a lookup dinamico

- RolesController /api/v1/roles CRUD admin-only: GET list, GET {codigo}, POST, PUT, DELETE (soft-delete con guard 409)
- ExceptionFilter: mapea RolNotFound (404), RolAlreadyExists (409), RolInUse (409)
- DI: registra 5 handlers de Roles (Application) y IRolRepository/RolRepository (Infrastructure)
- CreateUsuarioCommandValidator: reemplaza whitelist hardcoded por IRolRepository.ExistsActiveByCodigoAsync via MustAsync; constructor recibe (AuthOptions, IRolRepository)
- Tests: 202 verdes (173 application + 29 api). Nuevas: RolesEndpointTests (13 integration), CreateUsuarioCommandValidatorTests reescrito con NSubstitute mock, CreateUsuario_WithInactiveRol_Returns400 en Api.Tests
- Fix: ApiIntegration pasa de IClassFixture (N factories) a ICollectionFixture (1 factory shared) — evitaba ObjectDisposedException sobre RSABCrypt al compartir coleccion con multiples test classes
- tests/tests.runsettings: MaxCpuCount=1 para evitar race entre assemblies sobre SIGCM2_Test
This commit is contained in:
2026-04-15 12:50:24 -03:00
parent 34b714750a
commit 6f999b8fcd
11 changed files with 722 additions and 80 deletions

View File

@@ -4,6 +4,12 @@ using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Auth.Login;
using SIGCM2.Application.Auth.Logout;
using SIGCM2.Application.Auth.Refresh;
using SIGCM2.Application.Roles.Create;
using SIGCM2.Application.Roles.Deactivate;
using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Application.Roles.Get;
using SIGCM2.Application.Roles.List;
using SIGCM2.Application.Roles.Update;
using SIGCM2.Application.Usuarios.Create;
namespace SIGCM2.Application;
@@ -18,6 +24,13 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<LogoutCommand, LogoutResponseDto>, LogoutCommandHandler>();
services.AddScoped<ICommandHandler<CreateUsuarioCommand, UsuarioCreatedDto>, CreateUsuarioCommandHandler>();
// Roles (UDT-004)
services.AddScoped<ICommandHandler<ListRolesQuery, IReadOnlyList<RolDto>>, ListRolesQueryHandler>();
services.AddScoped<ICommandHandler<GetRolByCodigoQuery, RolDto>, GetRolByCodigoQueryHandler>();
services.AddScoped<ICommandHandler<CreateRolCommand, RolCreatedDto>, CreateRolCommandHandler>();
services.AddScoped<ICommandHandler<UpdateRolCommand, RolDto>, UpdateRolCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateRolCommand, RolDto>, DeactivateRolCommandHandler>();
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -1,27 +1,18 @@
using FluentValidation;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Auth;
namespace SIGCM2.Application.Usuarios.Create;
public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsuarioCommand>
{
// Whitelist aligned with canonical seeds in dbo.Rol (migration V003).
// Phase 5 of UDT-004 will replace this array with an async lookup against IRolRepository.
private static readonly string[] ValidRoles =
[
"admin", "cajero", "operador_ctacte", "picadora",
"jefe_publicidad", "productor", "diagramacion", "reportes"
];
private const int UsernameMinLength = 3;
private const int UsernameMaxLength = 50;
private const int NombreMaxLength = 100;
private const int ApellidoMaxLength = 100;
private const int EmailMaxLength = 150;
public CreateUsuarioCommandValidator() : this(new AuthOptions()) { }
public CreateUsuarioCommandValidator(AuthOptions authOptions)
public CreateUsuarioCommandValidator(AuthOptions authOptions, IRolRepository rolRepository)
{
RuleFor(x => x.Username)
.NotEmpty().WithMessage("El nombre de usuario es requerido.")
@@ -52,10 +43,13 @@ public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsua
.MaximumLength(EmailMaxLength).WithMessage($"El email no puede superar los {EmailMaxLength} caracteres.")
.When(x => x.Email is not null);
// Rol: lookup dinámico contra dbo.Rol (UDT-004).
// MustAsync requiere ValidateAsync en el call site — controllers ya usan ValidateAsync.
RuleFor(x => x.Rol)
.NotEmpty().WithMessage("El rol es requerido.")
.Must(r => ValidRoles.Contains(r))
.WithMessage($"El rol debe ser uno de: {string.Join(", ", ValidRoles)}.");
.MustAsync(async (codigo, ct) =>
!string.IsNullOrEmpty(codigo) && await rolRepository.ExistsActiveByCodigoAsync(codigo, ct))
.WithMessage("El rol debe existir en el sistema y estar activo.");
}
private static bool ContainsLetter(string value) =>