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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (1–100) 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/>
|
||||
|
||||
Reference in New Issue
Block a user