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:
@@ -4,6 +4,7 @@ using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Products.Prices;
|
||||
using SIGCM2.Application.Products.Prices.AddPrice;
|
||||
using SIGCM2.Domain.Entities;
|
||||
@@ -50,11 +51,10 @@ public class AddProductPriceCommandHandlerTests
|
||||
_pricesRepo.AddAsync(1, Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
|
||||
.Returns((10L, (long?)null));
|
||||
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ProductPrice>
|
||||
{
|
||||
MakePrice(10, 1, 150m, Today)
|
||||
}.AsReadOnly() as IReadOnlyList<ProductPrice>);
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<ProductPrice>(
|
||||
new List<ProductPrice> { MakePrice(10, 1, 150m, Today) },
|
||||
1, 2, 1));
|
||||
|
||||
_handler = new AddProductPriceCommandHandler(_pricesRepo, _productsRepo, _audit, _time);
|
||||
}
|
||||
@@ -110,12 +110,14 @@ public class AddProductPriceCommandHandlerTests
|
||||
_pricesRepo.AddAsync(1, Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
|
||||
.Returns((20L, (long?)5L)); // newId=20, closedId=5
|
||||
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ProductPrice>
|
||||
{
|
||||
MakePrice(20, 1, 200m, Tomorrow),
|
||||
MakePrice(5, 1, 150m, Today, pvt: Tomorrow.AddDays(-1))
|
||||
}.AsReadOnly() as IReadOnlyList<ProductPrice>);
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<ProductPrice>(
|
||||
new List<ProductPrice>
|
||||
{
|
||||
MakePrice(20, 1, 200m, Tomorrow),
|
||||
MakePrice(5, 1, 150m, Today, pvt: Tomorrow.AddDays(-1))
|
||||
},
|
||||
1, 2, 2));
|
||||
|
||||
var result = await _handler.Handle(ValidCmd() with { Price = 200m, PriceValidFrom = Tomorrow });
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Products.Prices;
|
||||
using SIGCM2.Application.Products.Prices.GetHistory;
|
||||
using SIGCM2.Domain.Entities;
|
||||
@@ -9,8 +10,9 @@ using SIGCM2.Domain.Exceptions;
|
||||
namespace SIGCM2.Application.Tests.Products.Prices;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — GetProductPricesQueryHandler tests.
|
||||
/// Covers: §REQ-4.1 (historial descending), §REQ-4.3 (lista vacía), §REQ-3.3 (producto no existe → 404).
|
||||
/// PRD-003 (paginated) — GetProductPricesQueryHandler tests.
|
||||
/// Covers: §P.1 (defaults), §P.3 (empty), §P.4 (pageSize clamp), §P.5 (page clamp),
|
||||
/// §REQ-4.1 (historial descending), §REQ-4.3 (lista vacía), §REQ-3.3 (producto no existe → 404).
|
||||
/// </summary>
|
||||
public class GetProductPricesQueryHandlerTests
|
||||
{
|
||||
@@ -31,24 +33,30 @@ public class GetProductPricesQueryHandlerTests
|
||||
new(id, ProductId: 1, Price: 100m * id, pvf, pvt,
|
||||
FechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
private static PagedResult<ProductPrice> MakePagedResult(
|
||||
IReadOnlyList<ProductPrice> items, int page = 1, int pageSize = 20, int? total = null) =>
|
||||
new(items, page, pageSize, total ?? items.Count);
|
||||
|
||||
public GetProductPricesQueryHandlerTests()
|
||||
{
|
||||
_productsRepo.GetByIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(ActiveProduct());
|
||||
|
||||
// default: lista con 2 precios, el repo ya los devuelve descending (responsabilidad del repo)
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ProductPrice>
|
||||
{
|
||||
MakePrice(3, Date3), // activo (pvt=null)
|
||||
MakePrice(2, Date2, Date3.AddDays(-1)), // cerrado
|
||||
MakePrice(1, Date1, Date2.AddDays(-1)) // cerrado más antiguo
|
||||
}.AsReadOnly() as IReadOnlyList<ProductPrice>);
|
||||
// default: 3 precios devueltos descending por el repo
|
||||
var defaultItems = new List<ProductPrice>
|
||||
{
|
||||
MakePrice(3, Date3),
|
||||
MakePrice(2, Date2, Date3.AddDays(-1)),
|
||||
MakePrice(1, Date1, Date2.AddDays(-1))
|
||||
};
|
||||
_pricesRepo
|
||||
.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(MakePagedResult(defaultItems));
|
||||
|
||||
_handler = new GetProductPricesQueryHandler(_pricesRepo, _productsRepo);
|
||||
}
|
||||
|
||||
// ── Orden descending ────────────────────────────────────────────────────────
|
||||
// ── Orden descending y mapping ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsAllPrices_InDescendingOrder()
|
||||
@@ -56,10 +64,10 @@ public class GetProductPricesQueryHandlerTests
|
||||
// §REQ-4.1 — historial completo ordenado descending por PriceValidFrom
|
||||
var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1));
|
||||
|
||||
result.Should().HaveCount(3);
|
||||
result[0].PriceValidFrom.Should().Be(Date3); // más reciente primero
|
||||
result[1].PriceValidFrom.Should().Be(Date2);
|
||||
result[2].PriceValidFrom.Should().Be(Date1);
|
||||
result.Items.Should().HaveCount(3);
|
||||
result.Items[0].PriceValidFrom.Should().Be(Date3);
|
||||
result.Items[1].PriceValidFrom.Should().Be(Date2);
|
||||
result.Items[2].PriceValidFrom.Should().Be(Date1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -67,22 +75,76 @@ public class GetProductPricesQueryHandlerTests
|
||||
{
|
||||
var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1));
|
||||
|
||||
result[0].IsActive.Should().BeTrue(); // pvt=null → activo
|
||||
result[1].IsActive.Should().BeFalse(); // pvt IS NOT NULL → cerrado
|
||||
result.Items[0].IsActive.Should().BeTrue(); // pvt=null → activo
|
||||
result.Items[1].IsActive.Should().BeFalse(); // pvt IS NOT NULL → cerrado
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsPagedResultShape_WithCorrectMeta()
|
||||
{
|
||||
// §P.1 — defaults page=1, pageSize=20 forwarded to repo and reflected in result
|
||||
var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1));
|
||||
|
||||
result.Page.Should().Be(1);
|
||||
result.PageSize.Should().Be(20);
|
||||
}
|
||||
|
||||
// ── Lista vacía (nuevo producto sin precios) ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EmptyHistory_ReturnsEmptyList()
|
||||
public async Task Handle_EmptyHistory_ReturnsEmptyPagedResult()
|
||||
{
|
||||
// §REQ-4.3 — nuevo producto aún no tiene precios → lista vacía (no 404)
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ProductPrice>().AsReadOnly() as IReadOnlyList<ProductPrice>);
|
||||
// §REQ-4.3 / §P.6 — nuevo producto aún no tiene precios → items vacíos (no 404)
|
||||
_pricesRepo
|
||||
.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<ProductPrice>(new List<ProductPrice>(), 1, 20, 0));
|
||||
|
||||
var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1));
|
||||
|
||||
result.Should().BeEmpty();
|
||||
result.Items.Should().BeEmpty();
|
||||
result.Total.Should().Be(0);
|
||||
}
|
||||
|
||||
// ── Clamping defensivo ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PageZero_ClampsToOne()
|
||||
{
|
||||
// §P.5 — page=0 → Math.Max(1,0) = 1
|
||||
await _handler.Handle(new GetProductPricesQuery(ProductId: 1, Page: 0));
|
||||
|
||||
await _pricesRepo.Received(1)
|
||||
.GetByProductIdAsync(1, page: 1, pageSize: 20, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PageNegative_ClampsToOne()
|
||||
{
|
||||
// §P.5 — page=-5 → Math.Max(1,-5) = 1
|
||||
await _handler.Handle(new GetProductPricesQuery(ProductId: 1, Page: -5));
|
||||
|
||||
await _pricesRepo.Received(1)
|
||||
.GetByProductIdAsync(1, page: 1, pageSize: 20, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PageSizeOver100_ClampsTo100()
|
||||
{
|
||||
// §P.4 — pageSize=500 → Math.Clamp(500,1,100) = 100
|
||||
await _handler.Handle(new GetProductPricesQuery(ProductId: 1, PageSize: 500));
|
||||
|
||||
await _pricesRepo.Received(1)
|
||||
.GetByProductIdAsync(1, page: 1, pageSize: 100, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PageSizeZero_ClampsToOne()
|
||||
{
|
||||
// pageSize=0 → Math.Clamp(0,1,100) = 1
|
||||
await _handler.Handle(new GetProductPricesQuery(ProductId: 1, PageSize: 0));
|
||||
|
||||
await _pricesRepo.Received(1)
|
||||
.GetByProductIdAsync(1, page: 1, pageSize: 1, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Producto inexistente ────────────────────────────────────────────────────
|
||||
@@ -108,6 +170,6 @@ public class GetProductPricesQueryHandlerTests
|
||||
await act.Should().ThrowAsync<ProductNotFoundException>();
|
||||
|
||||
await _pricesRepo.DidNotReceive()
|
||||
.GetByProductIdAsync(99, Arg.Any<CancellationToken>());
|
||||
.GetByProductIdAsync(99, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Dapper;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
using SIGCM2.Infrastructure.Persistence;
|
||||
using SIGCM2.TestSupport;
|
||||
@@ -337,7 +338,7 @@ public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime
|
||||
// Batch 4 — Via ProductPriceRepository (Dapper wrapper)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// §REQ-4.1 — GetByProductIdAsync orders descending by PriceValidFrom
|
||||
// §REQ-4.1 / §P.1 — GetByProductIdAsync (paginated) orders descending by PriceValidFrom
|
||||
[Fact]
|
||||
public async Task GetByProductIdAsync_MultipleRows_OrdersDescendingByPriceValidFrom()
|
||||
{
|
||||
@@ -350,22 +351,93 @@ public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime
|
||||
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 300.00m, new DateOnly(2026, 5, 1));
|
||||
|
||||
var repo = BuildRepository();
|
||||
var result = await repo.GetByProductIdAsync(_defaultProductId);
|
||||
var result = await repo.GetByProductIdAsync(_defaultProductId, page: 1, pageSize: 20);
|
||||
|
||||
result.Should().HaveCount(3);
|
||||
result[0].PriceValidFrom.Should().Be(new DateOnly(2026, 5, 1), "most recent first");
|
||||
result[1].PriceValidFrom.Should().Be(new DateOnly(2026, 3, 1));
|
||||
result[2].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 1));
|
||||
result.Total.Should().Be(3);
|
||||
result.Items.Should().HaveCount(3);
|
||||
result.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 5, 1), "most recent first");
|
||||
result.Items[1].PriceValidFrom.Should().Be(new DateOnly(2026, 3, 1));
|
||||
result.Items[2].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 1));
|
||||
}
|
||||
|
||||
// §REQ-4.3 — GetByProductIdAsync returns empty list when no history
|
||||
// §REQ-4.3 / §P.6 — GetByProductIdAsync returns empty PagedResult when no history
|
||||
[Fact]
|
||||
public async Task GetByProductIdAsync_NoHistory_ReturnsEmptyList()
|
||||
public async Task GetByProductIdAsync_NoHistory_ReturnsEmptyPagedResult()
|
||||
{
|
||||
var repo = BuildRepository();
|
||||
var result = await repo.GetByProductIdAsync(_defaultProductId);
|
||||
var result = await repo.GetByProductIdAsync(_defaultProductId, page: 1, pageSize: 20);
|
||||
|
||||
result.Should().BeEmpty("product exists but has no price history yet");
|
||||
result.Items.Should().BeEmpty("product exists but has no price history yet");
|
||||
result.Total.Should().Be(0);
|
||||
result.Page.Should().Be(1);
|
||||
result.PageSize.Should().Be(20);
|
||||
}
|
||||
|
||||
// §P.2 — OFFSET/FETCH: page 2 with pageSize=2 returns correct items
|
||||
[Fact]
|
||||
public async Task GetByProductIdAsync_Page2_ReturnsCorrectOffset()
|
||||
{
|
||||
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await seedConn.OpenAsync();
|
||||
|
||||
// Seed 5 prices: PVF Jan-1 through Jan-5 (DESC order: Jan5, Jan4, Jan3, Jan2, Jan1)
|
||||
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
|
||||
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 200.00m, new DateOnly(2026, 1, 2));
|
||||
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 300.00m, new DateOnly(2026, 1, 3));
|
||||
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 400.00m, new DateOnly(2026, 1, 4));
|
||||
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 500.00m, new DateOnly(2026, 1, 5));
|
||||
|
||||
var repo = BuildRepository();
|
||||
// page=2, pageSize=2 → skip 2 → items at rank 3 and 4 (Jan3, Jan2)
|
||||
var result = await repo.GetByProductIdAsync(_defaultProductId, page: 2, pageSize: 2);
|
||||
|
||||
result.Total.Should().Be(5);
|
||||
result.Page.Should().Be(2);
|
||||
result.PageSize.Should().Be(2);
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 3), "rank 3 in DESC order");
|
||||
result.Items[1].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 2), "rank 4 in DESC order");
|
||||
}
|
||||
|
||||
// §P.3 — OFFSET/FETCH: page beyond total → empty items, correct total
|
||||
[Fact]
|
||||
public async Task GetByProductIdAsync_PageBeyondTotal_ReturnsEmptyItemsWithCorrectTotal()
|
||||
{
|
||||
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await seedConn.OpenAsync();
|
||||
|
||||
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
|
||||
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 200.00m, new DateOnly(2026, 2, 1));
|
||||
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 300.00m, new DateOnly(2026, 3, 1));
|
||||
|
||||
var repo = BuildRepository();
|
||||
var result = await repo.GetByProductIdAsync(_defaultProductId, page: 100, pageSize: 10);
|
||||
|
||||
result.Total.Should().Be(3, "COUNT always reflects actual total regardless of page");
|
||||
result.Items.Should().BeEmpty("offset far beyond available data");
|
||||
result.Page.Should().Be(100);
|
||||
}
|
||||
|
||||
// §P.2 no-overlap — two different pages have no overlapping items
|
||||
[Fact]
|
||||
public async Task GetByProductIdAsync_TwoPages_HaveNoOverlap()
|
||||
{
|
||||
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await seedConn.OpenAsync();
|
||||
|
||||
for (var i = 1; i <= 6; i++)
|
||||
await ExecAddPriceSpAsync(seedConn, _defaultProductId, i * 100m, new DateOnly(2026, 1, i));
|
||||
|
||||
var repo = BuildRepository();
|
||||
var page1 = await repo.GetByProductIdAsync(_defaultProductId, page: 1, pageSize: 3);
|
||||
var page2 = await repo.GetByProductIdAsync(_defaultProductId, page: 2, pageSize: 3);
|
||||
|
||||
page1.Items.Select(p => p.PriceValidFrom)
|
||||
.Intersect(page2.Items.Select(p => p.PriceValidFrom))
|
||||
.Should().BeEmpty("pages must not overlap");
|
||||
|
||||
page1.Total.Should().Be(6);
|
||||
page2.Total.Should().Be(6);
|
||||
}
|
||||
|
||||
// §REQ-4.4 — GetActiveAsync: exact boundary PriceValidFrom = query date → returns row
|
||||
|
||||
Reference in New Issue
Block a user