feat(api): UDT-004 dominio + repositorio + application roles (tdd)

- Migraciones V003 (tabla Rol + 8 seeds canonicos) y V004 (drop CK + FK Usuario.Rol)
- Dominio: Rol entity + 3 excepciones (RolNotFound/AlreadyExists/InUse)
- Infraestructura: RolRepository (Dapper) con List/Get/ExistsActive/Add/Update/HasActiveUsuarios
- Application: CRUD queries y commands (List, Get, Create, Update, Deactivate) + validators (codigo regex ^[a-z][a-z0-9_]*$)
- Validator UDT-003: whitelist alineada a codigos canonicos (full IRolRepository lookup diferido a Phase 5.1)
- Tests: 169 application + 15 api (todos verdes). Respawn configurado para re-seedear Rol canonical post-reset.
- Estricto TDD: RED/GREEN/TRIANGULATE en todos los handlers nuevos.
This commit is contained in:
2026-04-15 12:31:29 -03:00
parent e0e9ec3b88
commit 34b714750a
37 changed files with 1510 additions and 27 deletions

View File

@@ -0,0 +1,13 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface IRolRepository
{
Task<IReadOnlyList<Rol>> ListAsync(CancellationToken ct = default);
Task<Rol?> GetByCodigoAsync(string codigo, CancellationToken ct = default);
Task<bool> ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default);
Task<int> AddAsync(Rol rol, CancellationToken ct = default);
Task<bool> UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default);
Task<bool> HasActiveUsuariosAsync(string codigo, CancellationToken ct = default);
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Roles.Create;
public sealed record CreateRolCommand(
string Codigo,
string Nombre,
string? Descripcion);

View File

@@ -0,0 +1,36 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Roles.Create;
public sealed class CreateRolCommandHandler : ICommandHandler<CreateRolCommand, RolCreatedDto>
{
private readonly IRolRepository _repository;
public CreateRolCommandHandler(IRolRepository repository)
{
_repository = repository;
}
public async Task<RolCreatedDto> Handle(CreateRolCommand command)
{
// Check-then-insert: explicit check produces a clear 409 message.
// SqlException 2627 (UQ violation) acts as race-condition fallback — caught in ExceptionFilter.
var existing = await _repository.GetByCodigoAsync(command.Codigo);
if (existing is not null)
throw new RolAlreadyExistsException(command.Codigo);
var rol = Rol.ForCreation(command.Codigo, command.Nombre, command.Descripcion);
var newId = await _repository.AddAsync(rol);
return new RolCreatedDto(
Id: newId,
Codigo: rol.Codigo,
Nombre: rol.Nombre,
Descripcion: rol.Descripcion,
Activo: rol.Activo);
}
}

View File

@@ -0,0 +1,31 @@
using FluentValidation;
namespace SIGCM2.Application.Roles.Create;
public sealed class CreateRolCommandValidator : AbstractValidator<CreateRolCommand>
{
private const int CodigoMinLength = 3;
private const int CodigoMaxLength = 30;
private const int NombreMaxLength = 60;
private const int DescripcionMaxLength = 250;
public CreateRolCommandValidator()
{
RuleFor(x => x.Codigo)
.NotEmpty().WithMessage("El código es requerido.")
.Length(CodigoMinLength, CodigoMaxLength)
.WithMessage($"El código debe tener entre {CodigoMinLength} y {CodigoMaxLength} caracteres.")
.Matches(@"^[a-z][a-z0-9_]*$")
.WithMessage("El código debe empezar con una letra minúscula y contener solo minúsculas, dígitos o guion bajo.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre es requerido.")
.MaximumLength(NombreMaxLength)
.WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
RuleFor(x => x.Descripcion)
.MaximumLength(DescripcionMaxLength)
.WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.")
.When(x => x.Descripcion is not null);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Roles.Deactivate;
public sealed record DeactivateRolCommand(string Codigo);

View File

@@ -0,0 +1,36 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Roles.Deactivate;
public sealed class DeactivateRolCommandHandler : ICommandHandler<DeactivateRolCommand, RolDto>
{
private readonly IRolRepository _repository;
public DeactivateRolCommandHandler(IRolRepository repository)
{
_repository = repository;
}
public async Task<RolDto> Handle(DeactivateRolCommand command)
{
var existing = await _repository.GetByCodigoAsync(command.Codigo)
?? throw new RolNotFoundException(command.Codigo);
// Guard: block soft-delete when active usuarios reference this rol.
if (await _repository.HasActiveUsuariosAsync(command.Codigo))
throw new RolInUseException(command.Codigo);
var updated = await _repository.UpdateAsync(
existing.Codigo, existing.Nombre, existing.Descripcion, activo: false);
if (!updated)
throw new RolNotFoundException(command.Codigo);
var rol = await _repository.GetByCodigoAsync(command.Codigo)
?? throw new RolNotFoundException(command.Codigo);
return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion);
}
}

View File

@@ -0,0 +1,8 @@
namespace SIGCM2.Application.Roles.Dtos;
public sealed record RolCreatedDto(
int Id,
string Codigo,
string Nombre,
string? Descripcion,
bool Activo);

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Roles.Dtos;
public sealed record RolDto(
int Id,
string Codigo,
string Nombre,
string? Descripcion,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Roles.Get;
public sealed record GetRolByCodigoQuery(string Codigo);

View File

@@ -0,0 +1,25 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Roles.Get;
public sealed class GetRolByCodigoQueryHandler : ICommandHandler<GetRolByCodigoQuery, RolDto>
{
private readonly IRolRepository _repository;
public GetRolByCodigoQueryHandler(IRolRepository repository)
{
_repository = repository;
}
public async Task<RolDto> Handle(GetRolByCodigoQuery query)
{
var rol = await _repository.GetByCodigoAsync(query.Codigo);
if (rol is null)
throw new RolNotFoundException(query.Codigo);
return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Roles.List;
public sealed record ListRolesQuery();

View File

@@ -0,0 +1,23 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Roles.Dtos;
namespace SIGCM2.Application.Roles.List;
public sealed class ListRolesQueryHandler : ICommandHandler<ListRolesQuery, IReadOnlyList<RolDto>>
{
private readonly IRolRepository _repository;
public ListRolesQueryHandler(IRolRepository repository)
{
_repository = repository;
}
public async Task<IReadOnlyList<RolDto>> Handle(ListRolesQuery query)
{
var roles = await _repository.ListAsync();
return roles
.Select(r => new RolDto(r.Id, r.Codigo, r.Nombre, r.Descripcion, r.Activo, r.FechaCreacion, r.FechaModificacion))
.ToList();
}
}

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Roles.Update;
public sealed record UpdateRolCommand(
string Codigo,
string Nombre,
string? Descripcion,
bool Activo);

View File

@@ -0,0 +1,30 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Roles.Update;
public sealed class UpdateRolCommandHandler : ICommandHandler<UpdateRolCommand, RolDto>
{
private readonly IRolRepository _repository;
public UpdateRolCommandHandler(IRolRepository repository)
{
_repository = repository;
}
public async Task<RolDto> Handle(UpdateRolCommand command)
{
var updated = await _repository.UpdateAsync(
command.Codigo, command.Nombre, command.Descripcion, command.Activo);
if (!updated)
throw new RolNotFoundException(command.Codigo);
var rol = await _repository.GetByCodigoAsync(command.Codigo)
?? throw new RolNotFoundException(command.Codigo);
return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion);
}
}

View File

@@ -0,0 +1,27 @@
using FluentValidation;
namespace SIGCM2.Application.Roles.Update;
public sealed class UpdateRolCommandValidator : AbstractValidator<UpdateRolCommand>
{
private const int NombreMaxLength = 60;
private const int DescripcionMaxLength = 250;
public UpdateRolCommandValidator()
{
// Codigo is taken from the URL route — we don't re-validate format here,
// but we require it to be non-empty so handler always has a target to match.
RuleFor(x => x.Codigo)
.NotEmpty().WithMessage("El código es requerido.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre es requerido.")
.MaximumLength(NombreMaxLength)
.WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
RuleFor(x => x.Descripcion)
.MaximumLength(DescripcionMaxLength)
.WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.")
.When(x => x.Descripcion is not null);
}
}

View File

@@ -5,7 +5,13 @@ namespace SIGCM2.Application.Usuarios.Create;
public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsuarioCommand>
{
private static readonly string[] ValidRoles = ["admin", "vendedor", "tasador", "consulta"];
// 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;