feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)

- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
This commit is contained in:
2026-04-19 18:26:24 -03:00
parent 2d2e90fa3c
commit f6f24bc4be
4 changed files with 606 additions and 19 deletions

View File

@@ -0,0 +1,93 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Products.Prices;
using SIGCM2.Application.Products.Prices.AddPrice;
using SIGCM2.Application.Products.Prices.GetHistory;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-003: ProductPrices historic pricing management.
/// Read endpoint at GET /api/v1/products/{id}/prices — requires authentication (any role).
/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'.
/// </summary>
[ApiController]
public sealed class ProductPricesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<AddProductPriceCommand> _addValidator;
public ProductPricesController(
IDispatcher dispatcher,
IValidator<AddProductPriceCommand> addValidator)
{
_dispatcher = dispatcher;
_addValidator = addValidator;
}
// ── READ endpoint ──────────────────────────────────────────────────────────
/// <summary>
/// Returns the full price history for a Product, ordered descending by PriceValidFrom.
/// Returns 200 with empty array if the product has no prices yet.
/// Returns 404 if the product does not exist.
/// </summary>
[HttpGet("api/v1/products/{id:int}/prices")]
[Authorize]
[ProducesResponseType(typeof(IReadOnlyList<ProductPriceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductPrices([FromRoute] int id)
{
var query = new GetProductPricesQuery(id);
var result = await _dispatcher.Send<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>(query);
return Ok(result);
}
// ── WRITE endpoint ─────────────────────────────────────────────────────────
/// <summary>
/// Adds a new price to a Product. Closes the current active price if one exists.
/// PriceValidFrom must be >= today_AR and strictly greater than the active price's PriceValidFrom.
/// Returns 201 Created with Location header pointing to GET /api/v1/products/{id}/prices.
/// </summary>
[HttpPost("api/v1/admin/products/{id:int}/prices")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(AddProductPriceResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> AddProductPrice(
[FromRoute] int id,
[FromBody] AddProductPriceRequest request)
{
var command = new AddProductPriceCommand(
ProductId: id,
Price: request.Price,
PriceValidFrom: request.PriceValidFrom);
var validation = await _addValidator.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<AddProductPriceCommand, AddProductPriceResponse>(command);
return CreatedAtAction(nameof(GetProductPrices), new { id }, result);
}
}
// ── Request body record ───────────────────────────────────────────────────────
/// <summary>PRD-003: Add ProductPrice request body.</summary>
public sealed record AddProductPriceRequest(
decimal Price,
DateOnly PriceValidFrom);

View File

@@ -475,6 +475,45 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
// PRD-003: ProductPrices exceptions
case ProductPriceForwardOnlyException forwardOnlyEx:
context.Result = new ObjectResult(new
{
error = "product_price_forward_only",
message = forwardOnlyEx.Message,
productId = forwardOnlyEx.ProductId
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductPriceInvalidException priceInvalidEx:
context.Result = new ObjectResult(new
{
error = "product_price_invalid",
message = priceInvalidEx.Message,
field = priceInvalidEx.Field
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case ProductSinPrecioActivoException sinPrecioEx:
context.Result = new ObjectResult(new
{
error = "product_sin_precio_activo",
message = sinPrecioEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
// PRD-002: Product exceptions
case ProductNotFoundException productNotFoundEx:
context.Result = new ObjectResult(new

View File

@@ -44,30 +44,35 @@ public sealed class AddProductPriceCommandHandler
// 2. TX + SP + audit (fail-closed).
// El audit.LogAsync enlista en el mismo TransactionScope — si falla, rollback total.
using var tx = new TransactionScope(
// GetByProductIdAsync se ejecuta FUERA del scope (post-commit) para evitar
// "TransactionScope is already complete" al abrir una nueva conexión dentro del using.
long newId;
long? closedId;
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
TransactionScopeAsyncFlowOption.Enabled))
{
(newId, closedId) = await _pricesRepo.AddAsync(
command.ProductId, command.Price, command.PriceValidFrom);
var (newId, closedId) = await _pricesRepo.AddAsync(
command.ProductId, command.Price, command.PriceValidFrom);
await _audit.LogAsync(
action: "product_price.created",
targetType: "ProductPrice",
targetId: newId.ToString(),
metadata: new
{
after = new
await _audit.LogAsync(
action: "product_price.created",
targetType: "ProductPrice",
targetId: newId.ToString(),
metadata: new
{
command.ProductId,
command.Price,
priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"),
},
closedPriceId = closedId
});
after = new
{
command.ProductId,
command.Price,
priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"),
},
closedPriceId = closedId
});
tx.Complete();
tx.Complete();
} // TX disposed (committed) here — BEFORE the post-commit read below.
// 3. Compongo la respuesta post-commit con lectura de historial actualizado.
var prices = await _pricesRepo.GetByProductIdAsync(command.ProductId);