test(integration): pagination edge cases (prd-003-prices-pagination)
This commit is contained in:
@@ -304,6 +304,102 @@ public sealed class ProductPricesControllerTests : IAsyncLifetime
|
|||||||
paged!.Page.Should().Be(1, "page=0 must be clamped to 1");
|
paged!.Page.Should().Be(1, "page=0 must be clamped to 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>§P.4 boundary — pageSize=100 exacto → no clamping, boundary inclusivo.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPrices_PageSize100_Exact_Returns200()
|
||||||
|
{
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
// Seed 3 prices — all but the last have explicit PVT
|
||||||
|
for (var i = 1; i <= 3; i++)
|
||||||
|
{
|
||||||
|
var pvt = i < 3 ? (DateOnly?)new DateOnly(2026, 1, i) : null;
|
||||||
|
await SeedPriceDirectAsync(productId, i * 10m, new DateOnly(2026, 1, i), pvt);
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = GetAdminToken();
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=100", token: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||||
|
paged.Should().NotBeNull();
|
||||||
|
paged!.PageSize.Should().Be(100, "pageSize=100 is the upper boundary — must NOT be clamped further");
|
||||||
|
paged.Items.Should().HaveCount(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§P.4 boundary — pageSize=101 → clamp to 100.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPrices_PageSize101_ClampsTo100()
|
||||||
|
{
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
await SeedPriceDirectAsync(productId, 50m, new DateOnly(2026, 2, 1), null);
|
||||||
|
|
||||||
|
var token = GetAdminToken();
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=101", token: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||||
|
paged.Should().NotBeNull();
|
||||||
|
paged!.PageSize.Should().Be(100, "pageSize=101 must be clamped to 100");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§P.4 boundary — pageSize=1000 → clamp to 100.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPrices_PageSize1000_ClampsTo100()
|
||||||
|
{
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
await SeedPriceDirectAsync(productId, 75m, new DateOnly(2026, 3, 1), null);
|
||||||
|
|
||||||
|
var token = GetAdminToken();
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=1000", token: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||||
|
paged.Should().NotBeNull();
|
||||||
|
paged!.PageSize.Should().Be(100, "pageSize=1000 must be clamped to max 100");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§P.5 boundary — page=-5 → clamp to 1.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPrices_PageNegative_ClampsToOne()
|
||||||
|
{
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
await SeedPriceDirectAsync(productId, 120m, new DateOnly(2026, 4, 1), null);
|
||||||
|
|
||||||
|
var token = GetAdminToken();
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?page=-5", token: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||||
|
paged.Should().NotBeNull();
|
||||||
|
paged!.Page.Should().Be(1, "page=-5 must be clamped to 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§P.4 boundary — pageSize=0 → clamp to 1 (minimum).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPrices_PageSizeZero_ClampsToOne()
|
||||||
|
{
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
await SeedPriceDirectAsync(productId, 90m, new DateOnly(2026, 5, 1), null);
|
||||||
|
|
||||||
|
var token = GetAdminToken();
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=0", token: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||||
|
paged.Should().NotBeNull();
|
||||||
|
paged!.PageSize.Should().Be(1, "pageSize=0 must be clamped to minimum 1");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>§P.7 — Producto inexistente → 404.</summary>
|
/// <summary>§P.7 — Producto inexistente → 404.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPrices_ProductNotFound_Returns404()
|
public async Task GetPrices_ProductNotFound_Returns404()
|
||||||
|
|||||||
@@ -440,6 +440,77 @@ public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime
|
|||||||
page2.Total.Should().Be(6);
|
page2.Total.Should().Be(6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §B3.2 — 30 prices: page 1 and page 2 (pageSize=10) are fully disjoint,
|
||||||
|
// union = 20 distinct items, each page is ordered DESC by PriceValidFrom.
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByProductIdAsync_ThirtyPrices_TwoPages_AreDisjointAndOrderedDesc()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Insert 30 prices directly (bypass SP to avoid forward-only complexity).
|
||||||
|
// All rows get an explicit PriceValidTo so no unique-index violation.
|
||||||
|
// PVF: 2026-01-01 to 2026-01-30 (ascending), all closed (PVT = PVF + 1 day).
|
||||||
|
// Except row 30 which has PVT = NULL (the single active row).
|
||||||
|
for (var i = 1; i <= 30; i++)
|
||||||
|
{
|
||||||
|
DateTime? pvt = i < 30
|
||||||
|
? (DateTime?)new DateTime(2026, 1, i + 1)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await seedConn.ExecuteAsync("""
|
||||||
|
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion)
|
||||||
|
VALUES (@ProductId, @Price, @PriceValidFrom, @PriceValidTo, SYSUTCDATETIME())
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ProductId = _defaultProductId,
|
||||||
|
Price = (decimal)(i * 10),
|
||||||
|
PriceValidFrom = new DateTime(2026, 1, i),
|
||||||
|
PriceValidTo = pvt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
var page1 = await repo.GetByProductIdAsync(_defaultProductId, page: 1, pageSize: 10);
|
||||||
|
var page2 = await repo.GetByProductIdAsync(_defaultProductId, page: 2, pageSize: 10);
|
||||||
|
|
||||||
|
// Both pages must reflect the full dataset
|
||||||
|
page1.Total.Should().Be(30);
|
||||||
|
page2.Total.Should().Be(30);
|
||||||
|
|
||||||
|
// Each page must have exactly 10 items
|
||||||
|
page1.Items.Should().HaveCount(10);
|
||||||
|
page2.Items.Should().HaveCount(10);
|
||||||
|
|
||||||
|
// Page meta
|
||||||
|
page1.Page.Should().Be(1);
|
||||||
|
page1.PageSize.Should().Be(10);
|
||||||
|
page2.Page.Should().Be(2);
|
||||||
|
page2.PageSize.Should().Be(10);
|
||||||
|
|
||||||
|
// The two ID sets must be fully disjoint
|
||||||
|
var ids1 = page1.Items.Select(p => p.PriceValidFrom).ToHashSet();
|
||||||
|
var ids2 = page2.Items.Select(p => p.PriceValidFrom).ToHashSet();
|
||||||
|
ids1.Intersect(ids2).Should().BeEmpty("pages must not share any items");
|
||||||
|
|
||||||
|
// Union covers exactly 20 distinct items
|
||||||
|
ids1.Union(ids2).Should().HaveCount(20, "union of two pages of 10 must yield 20 distinct items");
|
||||||
|
|
||||||
|
// Page 1 must be ordered DESC — first item is PVF Jan 30 (most recent)
|
||||||
|
page1.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 30),
|
||||||
|
"page 1 rank-1 must be the most recent price (DESC)");
|
||||||
|
page1.Items[9].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 21),
|
||||||
|
"page 1 rank-10 must be Jan 21");
|
||||||
|
|
||||||
|
// Page 2 must continue DESC — first item is PVF Jan 20
|
||||||
|
page2.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 20),
|
||||||
|
"page 2 rank-1 must be Jan 20 (rank 11 globally in DESC order)");
|
||||||
|
page2.Items[9].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 11),
|
||||||
|
"page 2 rank-10 must be Jan 11");
|
||||||
|
}
|
||||||
|
|
||||||
// §REQ-4.4 — GetActiveAsync: exact boundary PriceValidFrom = query date → returns row
|
// §REQ-4.4 — GetActiveAsync: exact boundary PriceValidFrom = query date → returns row
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetActiveAsync_ExactBoundaryPvf_ReturnsRow()
|
public async Task GetActiveAsync_ExactBoundaryPvf_ReturnsRow()
|
||||||
|
|||||||
Reference in New Issue
Block a user