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;

View File

@@ -0,0 +1,44 @@
namespace SIGCM2.Domain.Entities;
public sealed class Rol
{
public int Id { get; }
public string Codigo { get; }
public string Nombre { get; }
public string? Descripcion { get; }
public bool Activo { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
public Rol(
int id,
string codigo,
string nombre,
string? descripcion,
bool activo,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
Id = id;
Codigo = codigo;
Nombre = nombre;
Descripcion = descripcion;
Activo = activo;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
// Factory for creating a new Rol (Id=0 — DB assigns via IDENTITY; Activo=true; FechaCreacion set by DB default).
public static Rol ForCreation(string codigo, string nombre, string? descripcion)
{
return new Rol(
id: 0,
codigo: codigo,
nombre: nombre,
descripcion: descripcion,
activo: true,
fechaCreacion: default,
fechaModificacion: null
);
}
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Domain.Exceptions;
public sealed class RolAlreadyExistsException : Exception
{
public string Codigo { get; }
public RolAlreadyExistsException(string codigo)
: base($"El rol '{codigo}' ya existe.")
{
Codigo = codigo;
}
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Domain.Exceptions;
public sealed class RolInUseException : Exception
{
public string Codigo { get; }
public RolInUseException(string codigo)
: base($"El rol '{codigo}' no puede desactivarse porque existen usuarios activos que lo referencian.")
{
Codigo = codigo;
}
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Domain.Exceptions;
public sealed class RolNotFoundException : Exception
{
public string Codigo { get; }
public RolNotFoundException(string codigo)
: base($"El rol '{codigo}' no existe.")
{
Codigo = codigo;
}
}

View File

@@ -0,0 +1,129 @@
using Dapper;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Infrastructure.Persistence;
public sealed class RolRepository : IRolRepository
{
private readonly SqlConnectionFactory _connectionFactory;
public RolRepository(SqlConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<IReadOnlyList<Rol>> ListAsync(CancellationToken ct = default)
{
const string sql = """
SELECT Id, Codigo, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion
FROM dbo.Rol
ORDER BY Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<RolRow>(sql);
return rows.Select(MapRow).ToList();
}
public async Task<Rol?> GetByCodigoAsync(string codigo, CancellationToken ct = default)
{
const string sql = """
SELECT Id, Codigo, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion
FROM dbo.Rol
WHERE Codigo = @Codigo
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<RolRow>(sql, new { Codigo = codigo });
return row is null ? null : MapRow(row);
}
public async Task<bool> ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1) FROM dbo.Rol WHERE Codigo = @Codigo AND Activo = 1
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var count = await connection.ExecuteScalarAsync<int>(sql, new { Codigo = codigo });
return count > 0;
}
public async Task<int> AddAsync(Rol rol, CancellationToken ct = default)
{
// DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()).
const string sql = """
INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion)
OUTPUT INSERTED.Id
VALUES (@Codigo, @Nombre, @Descripcion)
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.ExecuteScalarAsync<int>(sql, new
{
rol.Codigo,
rol.Nombre,
rol.Descripcion,
});
}
public async Task<bool> UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.Rol
SET Nombre = @Nombre,
Descripcion = @Descripcion,
Activo = @Activo,
FechaModificacion = SYSUTCDATETIME()
WHERE Codigo = @Codigo;
SELECT @@ROWCOUNT;
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.ExecuteScalarAsync<int>(sql, new { Codigo = codigo, Nombre = nombre, Descripcion = descripcion, Activo = activo });
return rows > 0;
}
public async Task<bool> HasActiveUsuariosAsync(string codigo, CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1) FROM dbo.Usuario WHERE Rol = @Codigo AND Activo = 1
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var count = await connection.ExecuteScalarAsync<int>(sql, new { Codigo = codigo });
return count > 0;
}
private static Rol MapRow(RolRow row)
=> new(
id: row.Id,
codigo: row.Codigo,
nombre: row.Nombre,
descripcion: row.Descripcion,
activo: row.Activo,
fechaCreacion: row.FechaCreacion,
fechaModificacion: row.FechaModificacion);
private sealed record RolRow(
int Id,
string Codigo,
string Nombre,
string? Descripcion,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion);
}