feat(api): RubrosController + integration tests e2e + audit verification (CAT-001)

This commit is contained in:
2026-04-18 20:05:20 -03:00
parent f8e9d18379
commit 5e2323e0bc
3 changed files with 836 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Rubros.Create;
using SIGCM2.Application.Rubros.Deactivate;
using SIGCM2.Application.Rubros.Dtos;
using SIGCM2.Application.Rubros.GetById;
using SIGCM2.Application.Rubros.GetTree;
using SIGCM2.Application.Rubros.Move;
using SIGCM2.Application.Rubros.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// CAT-001: Rubro N-ary tree management.
/// Read endpoints at /api/v1/rubros — require authentication (any role).
/// Write endpoints at /api/v1/admin/rubros — require 'catalogo:rubros:gestionar'.
/// </summary>
[ApiController]
public sealed class RubrosController : ControllerBase
{
private readonly IDispatcher _dispatcher;
public RubrosController(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
// ── READ endpoints ─────────────────────────────────────────────────────────
/// <summary>Returns the full Rubro tree. Requires authentication.</summary>
[HttpGet("api/v1/rubros/tree")]
[Authorize]
[ProducesResponseType(typeof(IReadOnlyList<RubroTreeNodeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetRubroTree([FromQuery] bool incluirInactivos = false)
{
var query = new GetRubroTreeQuery(incluirInactivos);
var result = await _dispatcher.Send<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>(query);
return Ok(result);
}
/// <summary>Returns a single Rubro by id. Requires authentication.</summary>
[HttpGet("api/v1/rubros/{id:int}")]
[Authorize]
[ProducesResponseType(typeof(RubroDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetRubroById([FromRoute] int id)
{
var query = new GetRubroByIdQuery(id);
var result = await _dispatcher.Send<GetRubroByIdQuery, RubroDetailDto>(query);
return Ok(result);
}
// ── WRITE endpoints ────────────────────────────────────────────────────────
/// <summary>Creates a new Rubro. Requires catalogo:rubros:gestionar.</summary>
[HttpPost("api/v1/admin/rubros")]
[RequirePermission("catalogo:rubros:gestionar")]
[ProducesResponseType(typeof(RubroCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateRubro([FromBody] CreateRubroRequest request)
{
var command = new CreateRubroCommand(
Nombre: request.Nombre ?? string.Empty,
ParentId: request.ParentId,
TarifarioBaseId: request.TarifarioBaseId);
var result = await _dispatcher.Send<CreateRubroCommand, RubroCreatedDto>(command);
return CreatedAtAction(nameof(GetRubroById), new { id = result.Id }, result);
}
/// <summary>Updates a Rubro's nombre. Requires catalogo:rubros:gestionar.</summary>
[HttpPut("api/v1/admin/rubros/{id:int}")]
[RequirePermission("catalogo:rubros:gestionar")]
[ProducesResponseType(typeof(RubroUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> UpdateRubro([FromRoute] int id, [FromBody] UpdateRubroRequest request)
{
var command = new UpdateRubroCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty);
var result = await _dispatcher.Send<UpdateRubroCommand, RubroUpdatedDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a Rubro. Requires catalogo:rubros:gestionar.</summary>
[HttpDelete("api/v1/admin/rubros/{id:int}")]
[RequirePermission("catalogo:rubros:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeactivateRubro([FromRoute] int id)
{
var command = new DeactivateRubroCommand(id);
await _dispatcher.Send<DeactivateRubroCommand, RubroStatusDto>(command);
return NoContent();
}
/// <summary>Moves a Rubro to a new parent. Requires catalogo:rubros:gestionar.</summary>
[HttpPatch("api/v1/admin/rubros/{id:int}/mover")]
[RequirePermission("catalogo:rubros:gestionar")]
[ProducesResponseType(typeof(RubroMovedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> MoveRubro([FromRoute] int id, [FromBody] MoveRubroRequest request)
{
var command = new MoveRubroCommand(
Id: id,
NuevoParentId: request.NuevoParentId,
NuevoOrden: request.NuevoOrden);
var result = await _dispatcher.Send<MoveRubroCommand, RubroMovedDto>(command);
return Ok(result);
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>CAT-001: Create rubro request body.</summary>
public sealed record CreateRubroRequest(
string? Nombre,
int? ParentId,
int? TarifarioBaseId);
/// <summary>CAT-001: Update rubro request body.</summary>
public sealed record UpdateRubroRequest(
string? Nombre);
/// <summary>CAT-001: Move rubro request body.</summary>
public sealed record MoveRubroRequest(
int? NuevoParentId,
int NuevoOrden);

View File

@@ -60,6 +60,13 @@ using SIGCM2.Application.Usuarios.Reactivate;
using SIGCM2.Application.Usuarios.ResetPassword;
using SIGCM2.Application.Usuarios.Permisos;
using SIGCM2.Application.Usuarios.Update;
using SIGCM2.Application.Rubros.Create;
using SIGCM2.Application.Rubros.Update;
using SIGCM2.Application.Rubros.Deactivate;
using SIGCM2.Application.Rubros.Move;
using SIGCM2.Application.Rubros.GetTree;
using SIGCM2.Application.Rubros.GetById;
using SIGCM2.Application.Rubros.Dtos;
namespace SIGCM2.Application;
@@ -145,6 +152,14 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>();
services.AddScoped<ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>, GetHistorialIngresosBrutosQueryHandler>();
// Rubros (CAT-001)
services.AddScoped<ICommandHandler<CreateRubroCommand, RubroCreatedDto>, CreateRubroCommandHandler>();
services.AddScoped<ICommandHandler<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>();
services.AddScoped<ICommandHandler<MoveRubroCommand, RubroMovedDto>, MoveRubroCommandHandler>();
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();