feat(api): pagination on GET product prices (closes #47)

- GET /api/v1/products/{id}/prices now returns PagedResult<ProductPriceDto>
  with OFFSET/FETCH + COUNT via Dapper (two queries on same connection)
- Query params: ?page (default 1) and ?pageSize (default 20, max 100)
- Clamping: Math.Max(1, page) + Math.Clamp(pageSize, 1, 100) in handler
- Auth upgraded from [Authorize] to [RequirePermission("catalogo:productos:gestionar")]
- IProductPriceRepository.GetByProductIdAsync signature updated to paginated form
- AddProductPriceCommandHandler adapted to read back via page=1, pageSize=2
- TDD cycle: RED (tests updated to PagedResult shape) -> GREEN (implementation) -> REFACTOR
- Tests: 1418 total (1106 Application + 312 Api), 0 failures

closes #47
This commit is contained in:
2026-04-19 19:47:18 -03:00
parent da063ad677
commit 0dce3ee4ac
11 changed files with 425 additions and 98 deletions

View File

@@ -1,8 +1,8 @@
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.Products.Prices;
using SIGCM2.Application.Products.Prices.AddPrice;
using SIGCM2.Application.Products.Prices.GetHistory;
@@ -11,7 +11,7 @@ namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-003: ProductPrices historic pricing management.
/// Read endpoint at GET /api/v1/products/{id}/prices — requires authentication (any role).
/// Read endpoint at GET /api/v1/products/{id}/prices — requires 'catalogo:productos:gestionar'.
/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'.
/// </summary>
[ApiController]
@@ -31,19 +31,28 @@ public sealed class ProductPricesController : ControllerBase
// ── 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 a paginated page of price history for a Product, ordered descending by PriceValidFrom.
/// Defaults: page=1, pageSize=20. Clamping: page ≥ 1, pageSize ∈ [1, 100].
/// Returns 200 with empty items if the product has no prices yet or page is beyond total.
/// Returns 404 if the product does not exist.
/// Returns 401 if not authenticated, 403 if missing 'catalogo:productos:gestionar' permission.
/// </summary>
[HttpGet("api/v1/products/{id:int}/prices")]
[Authorize]
[ProducesResponseType(typeof(IReadOnlyList<ProductPriceDto>), StatusCodes.Status200OK)]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(PagedResult<ProductPriceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductPrices([FromRoute] int id)
public async Task<IActionResult> GetProductPrices(
[FromRoute] int id,
[FromQuery] int? page,
[FromQuery] int? pageSize)
{
var query = new GetProductPricesQuery(id);
var result = await _dispatcher.Send<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>(query);
var query = new GetProductPricesQuery(
ProductId: id,
Page: page ?? 1,
PageSize: pageSize ?? 20);
var result = await _dispatcher.Send<GetProductPricesQuery, PagedResult<ProductPriceDto>>(query);
return Ok(result);
}

View File

@@ -1,3 +1,4 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
@@ -21,11 +22,14 @@ public interface IProductPriceRepository
CancellationToken ct = default);
/// <summary>
/// Returns all price rows for the product, ordered descending by PriceValidFrom (active first).
/// Returns empty list when the product has no price history.
/// Returns a paginated page of price rows for the product, ordered descending by PriceValidFrom.
/// Caller is responsible for clamping page (≥ 1) and pageSize (1100) before calling.
/// Returns PagedResult with empty Items when the product has no price history or page is beyond total.
/// </summary>
Task<IReadOnlyList<ProductPrice>> GetByProductIdAsync(
Task<PagedResult<ProductPrice>> GetByProductIdAsync(
int productId,
int page,
int pageSize,
CancellationToken ct = default);
/// <summary>

View File

@@ -188,7 +188,7 @@ public static class DependencyInjection
// ProductPrices (PRD-003)
services.AddScoped<ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>, AddProductPriceCommandHandler>();
services.AddScoped<ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>, GetProductPricesQueryHandler>();
services.AddScoped<ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>, GetProductPricesQueryHandler>();
services.AddScoped<IProductPricingService, ProductPricingService>();
// ProductTypes (PRD-001)

View File

@@ -75,7 +75,10 @@ public sealed class AddProductPriceCommandHandler
} // 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);
// La primera página (pageSize=2) es suficiente: solo necesitamos el nuevo y el cerrado,
// que son siempre los más recientes (ORDER BY PriceValidFrom DESC).
var pricesPage = await _pricesRepo.GetByProductIdAsync(command.ProductId, page: 1, pageSize: 2);
var prices = pricesPage.Items;
var created = prices.Single(p => p.Id == newId);
var closed = closedId.HasValue
? prices.SingleOrDefault(p => p.Id == closedId.Value)

View File

@@ -1,8 +1,12 @@
namespace SIGCM2.Application.Products.Prices.GetHistory;
/// <summary>
/// PRD-003 — Query para obtener el historial de precios de un Product.
/// Devuelve lista ordenada descending por PriceValidFrom (activo primero).
/// PRD-003 (paginated) — Query para obtener el historial de precios de un Product.
/// Devuelve PagedResult ordenado descending por PriceValidFrom (activo primero).
/// Lanza ProductNotFoundException si el producto no existe.
/// Page y PageSize son clampeados por el handler: page ≥ 1, pageSize ∈ [1, 100].
/// </summary>
public sealed record GetProductPricesQuery(int ProductId);
public sealed record GetProductPricesQuery(
int ProductId,
int Page = 1,
int PageSize = 20);

View File

@@ -1,18 +1,19 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.Prices;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.Prices.GetHistory;
/// <summary>
/// PRD-003 — Handler de GetProductPricesQuery.
/// Verifica que el producto exista (404 si no), luego retorna historial de precios
/// ordenado descending por PriceValidFrom (responsabilidad del repo — SQL ORDER BY).
/// Lista vacía es válida (nuevo producto sin precios registrados aún).
/// PRD-003 (paginated) — Handler de GetProductPricesQuery.
/// Verifica que el producto exista (404 si no), aplica clamping defensivo de
/// page/pageSize y retorna PagedResult ordenado descending por PriceValidFrom.
/// Lista vacía es válida (nuevo producto sin precios o página más allá del total).
/// </summary>
public sealed class GetProductPricesQueryHandler
: ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>
: ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>
{
private readonly IProductPriceRepository _pricesRepo;
private readonly IProductRepository _productsRepo;
@@ -25,18 +26,24 @@ public sealed class GetProductPricesQueryHandler
_productsRepo = productsRepo;
}
public async Task<IReadOnlyList<ProductPriceDto>> Handle(GetProductPricesQuery query)
public async Task<PagedResult<ProductPriceDto>> Handle(GetProductPricesQuery query)
{
// Verifica existencia del producto (lanza 404 si no existe).
_ = await _productsRepo.GetByIdAsync(query.ProductId)
?? throw new ProductNotFoundException(query.ProductId);
var prices = await _pricesRepo.GetByProductIdAsync(query.ProductId);
// Clamping defensivo — igual al patrón de ListProductsQueryHandler.
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
return prices
var paged = await _pricesRepo.GetByProductIdAsync(query.ProductId, page, pageSize);
var dtoItems = paged.Items
.Select(p => new ProductPriceDto(
p.Id, p.ProductId, p.Price,
p.PriceValidFrom, p.PriceValidTo, p.IsActive))
.ToList();
return new PagedResult<ProductPriceDto>(dtoItems, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -2,6 +2,7 @@ using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -70,25 +71,42 @@ public sealed class ProductPriceRepository : IProductPriceRepository
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ProductPrice>> GetByProductIdAsync(
public async Task<PagedResult<ProductPrice>> GetByProductIdAsync(
int productId,
int page,
int pageSize,
CancellationToken ct = default)
{
// Uses IX_ProductPrices_Lookup (ProductId, PriceValidFrom DESC).
const string sql = """
// Two separate queries on the same open connection: COUNT first, then paginated DATA.
const string countSql = """
SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId
""";
const string dataSql = """
SELECT Id, ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion
FROM dbo.ProductPrices
WHERE ProductId = @ProductId
ORDER BY PriceValidFrom DESC, Id DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
""";
var offset = (page - 1) * pageSize;
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<ProductPriceRow>(
new CommandDefinition(sql, new { ProductId = productId }, cancellationToken: ct));
var total = await connection.ExecuteScalarAsync<int>(
new CommandDefinition(countSql, new { ProductId = productId }, cancellationToken: ct));
return rows.Select(MapRow).ToList();
var rows = await connection.QueryAsync<ProductPriceRow>(
new CommandDefinition(dataSql,
new { ProductId = productId, Offset = offset, PageSize = pageSize },
cancellationToken: ct));
var items = rows.Select(MapRow).ToList();
return new PagedResult<ProductPrice>(items, page, pageSize, total);
}
/// <inheritdoc/>