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

@@ -0,0 +1,127 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Application.Abstractions;
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;
namespace SIGCM2.Api.Controllers;
[ApiController]
[Route("api/v1/roles")]
[Authorize(Roles = "admin")]
public sealed class RolesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateRolCommand> _createValidator;
private readonly IValidator<UpdateRolCommand> _updateValidator;
public RolesController(
IDispatcher dispatcher,
IValidator<CreateRolCommand> createValidator,
IValidator<UpdateRolCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
/// <summary>Lists all roles (including inactive). Requires admin role.</summary>
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<RolDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> List()
{
var result = await _dispatcher.Send<ListRolesQuery, IReadOnlyList<RolDto>>(new ListRolesQuery());
return Ok(result);
}
/// <summary>Gets a role by its code. Requires admin role.</summary>
[HttpGet("{codigo}")]
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetByCodigo(string codigo)
{
var result = await _dispatcher.Send<GetRolByCodigoQuery, RolDto>(new GetRolByCodigoQuery(codigo));
return Ok(result);
}
/// <summary>Creates a new role. Requires admin role.</summary>
[HttpPost]
[ProducesResponseType(typeof(RolCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Create([FromBody] CreateRolRequest request)
{
var command = new CreateRolCommand(
Codigo: request.Codigo ?? string.Empty,
Nombre: request.Nombre ?? string.Empty,
Descripcion: request.Descripcion);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateRolCommand, RolCreatedDto>(command);
return CreatedAtAction(nameof(GetByCodigo), new { codigo = result.Codigo }, result);
}
/// <summary>Updates a role (codigo is immutable; route wins over body). Requires admin role.</summary>
[HttpPut("{codigo}")]
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(string codigo, [FromBody] UpdateRolRequest request)
{
// Codigo comes from the route — body.codigo (if present) is ignored by design.
var command = new UpdateRolCommand(
Codigo: codigo,
Nombre: request.Nombre ?? string.Empty,
Descripcion: request.Descripcion,
Activo: request.Activo);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateRolCommand, RolDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a role. 409 if active usuarios reference it. Requires admin role.</summary>
[HttpDelete("{codigo}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Deactivate(string codigo)
{
await _dispatcher.Send<DeactivateRolCommand, RolDto>(new DeactivateRolCommand(codigo));
return NoContent();
}
}
public sealed record CreateRolRequest(string? Codigo, string? Nombre, string? Descripcion);
public sealed record UpdateRolRequest(string? Nombre, string? Descripcion, bool Activo);