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,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