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