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:
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Roles.Create;
|
||||
|
||||
public sealed record CreateRolCommand(
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Roles.Deactivate;
|
||||
|
||||
public sealed record DeactivateRolCommand(string Codigo);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
8
src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs
Normal file
8
src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SIGCM2.Application.Roles.Dtos;
|
||||
|
||||
public sealed record RolCreatedDto(
|
||||
int Id,
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
bool Activo);
|
||||
10
src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs
Normal file
10
src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs
Normal 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);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Roles.Get;
|
||||
|
||||
public sealed record GetRolByCodigoQuery(string Codigo);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
3
src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs
Normal file
3
src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Roles.List;
|
||||
|
||||
public sealed record ListRolesQuery();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Roles.Update;
|
||||
|
||||
public sealed record UpdateRolCommand(
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
bool Activo);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user