feat(api): ProductTypesController + ExceptionFilter 4 casos PRD-001

CRUD endpoints con validación FluentValidation inline; 4 nuevas excepciones mapeadas
en ExceptionFilter; conteos de permisos 25→26 actualizados; 12 e2e tests nuevos.
This commit is contained in:
2026-04-19 09:57:11 -03:00
parent 936d1dc353
commit 170789886b
5 changed files with 505 additions and 8 deletions

View File

@@ -0,0 +1,184 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.ProductTypes.Create;
using SIGCM2.Application.ProductTypes.Deactivate;
using SIGCM2.Application.ProductTypes.GetById;
using SIGCM2.Application.ProductTypes.List;
using SIGCM2.Application.ProductTypes.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-001: ProductType catalog management.
/// Read endpoints at /api/v1/product-types — require authentication (any role).
/// Write endpoints at /api/v1/admin/product-types — require 'catalogo:tipos:gestionar'.
/// </summary>
[ApiController]
public sealed class ProductTypesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateProductTypeCommand> _createValidator;
private readonly IValidator<UpdateProductTypeCommand> _updateValidator;
public ProductTypesController(
IDispatcher dispatcher,
IValidator<CreateProductTypeCommand> createValidator,
IValidator<UpdateProductTypeCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
// ── READ endpoints ─────────────────────────────────────────────────────────
/// <summary>Returns a paginated list of ProductTypes. Requires authentication.</summary>
[HttpGet("api/v1/product-types")]
[Authorize]
[ProducesResponseType(typeof(PagedResult<ProductTypeListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ListProductTypes(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = true,
[FromQuery] string? search = null)
{
var query = new ListProductTypesQuery(page, pageSize, activo, search);
var result = await _dispatcher.Send<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>(query);
return Ok(result);
}
/// <summary>Returns a single ProductType by id. Requires authentication.</summary>
[HttpGet("api/v1/product-types/{id:int}")]
[Authorize]
[ProducesResponseType(typeof(ProductTypeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductTypeById([FromRoute] int id)
{
var query = new GetProductTypeByIdQuery(id);
var result = await _dispatcher.Send<GetProductTypeByIdQuery, ProductTypeDetailDto>(query);
return Ok(result);
}
// ── WRITE endpoints ────────────────────────────────────────────────────────
/// <summary>Creates a new ProductType. Requires catalogo:tipos:gestionar.</summary>
[HttpPost("api/v1/admin/product-types")]
[RequirePermission("catalogo:tipos:gestionar")]
[ProducesResponseType(typeof(ProductTypeCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateProductType([FromBody] CreateProductTypeRequest request)
{
var command = new CreateProductTypeCommand(
Nombre: request.Nombre ?? string.Empty,
HasDuration: request.HasDuration,
RequiresText: request.RequiresText,
RequiresCategory: request.RequiresCategory,
IsBundle: request.IsBundle,
AllowImages: request.AllowImages,
MaxImages: request.MaxImages,
MaxImageSizeMB: request.MaxImageSizeMB,
MaxImageWidth: request.MaxImageWidth,
MaxImageHeight: request.MaxImageHeight);
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<CreateProductTypeCommand, ProductTypeCreatedDto>(command);
return CreatedAtAction(nameof(GetProductTypeById), new { id = result.Id }, result);
}
/// <summary>Updates a ProductType. Requires catalogo:tipos:gestionar.</summary>
[HttpPut("api/v1/admin/product-types/{id:int}")]
[RequirePermission("catalogo:tipos:gestionar")]
[ProducesResponseType(typeof(ProductTypeUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> UpdateProductType([FromRoute] int id, [FromBody] UpdateProductTypeRequest request)
{
var command = new UpdateProductTypeCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
HasDuration: request.HasDuration,
RequiresText: request.RequiresText,
RequiresCategory: request.RequiresCategory,
IsBundle: request.IsBundle,
AllowImages: request.AllowImages,
MaxImages: request.MaxImages,
MaxImageSizeMB: request.MaxImageSizeMB,
MaxImageWidth: request.MaxImageWidth,
MaxImageHeight: request.MaxImageHeight);
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<UpdateProductTypeCommand, ProductTypeUpdatedDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a ProductType. Requires catalogo:tipos:gestionar.</summary>
[HttpDelete("api/v1/admin/product-types/{id:int}")]
[RequirePermission("catalogo:tipos:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeactivateProductType([FromRoute] int id)
{
var command = new DeactivateProductTypeCommand(id);
await _dispatcher.Send<DeactivateProductTypeCommand, ProductTypeStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRD-001: Create ProductType request body.</summary>
public sealed record CreateProductTypeRequest(
string? Nombre,
bool HasDuration = false,
bool RequiresText = false,
bool RequiresCategory = false,
bool IsBundle = false,
bool AllowImages = false,
int? MaxImages = null,
decimal? MaxImageSizeMB = null,
int? MaxImageWidth = null,
int? MaxImageHeight = null);
/// <summary>PRD-001: Update ProductType request body.</summary>
public sealed record UpdateProductTypeRequest(
string? Nombre,
bool HasDuration = false,
bool RequiresText = false,
bool RequiresCategory = false,
bool IsBundle = false,
bool AllowImages = false,
int? MaxImages = null,
decimal? MaxImageSizeMB = null,
int? MaxImageWidth = null,
int? MaxImageHeight = null);

View File

@@ -414,6 +414,55 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
// PRD-001: ProductType exceptions
case ProductTypeNotFoundException productTypeNotFoundEx:
context.Result = new ObjectResult(new
{
error = "product_type_not_found",
message = productTypeNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ProductTypeNombreDuplicadoException productTypeDupEx:
context.Result = new ObjectResult(new
{
error = "product_type_nombre_duplicado",
message = productTypeDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductTypeEnUsoException productTypeEnUsoEx:
context.Result = new ObjectResult(new
{
error = "product_type_en_uso",
message = productTypeEnUsoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductTypeFlagsIncoherentesException productTypeFlagsEx:
context.Result = new ObjectResult(new
{
error = "product_type_flags_incoherentes",
message = productTypeFlagsEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
// ADM-008: PuntoDeVenta exceptions
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
context.Result = new ObjectResult(new