diff --git a/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs b/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs index cad6079..1b111db 100644 --- a/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs @@ -304,6 +304,102 @@ public sealed class ProductPricesControllerTests : IAsyncLifetime paged!.Page.Should().Be(1, "page=0 must be clamped to 1"); } + /// §P.4 boundary — pageSize=100 exacto → no clamping, boundary inclusivo. + [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>(); + paged.Should().NotBeNull(); + paged!.PageSize.Should().Be(100, "pageSize=100 is the upper boundary — must NOT be clamped further"); + paged.Items.Should().HaveCount(3); + } + + /// §P.4 boundary — pageSize=101 → clamp to 100. + [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>(); + paged.Should().NotBeNull(); + paged!.PageSize.Should().Be(100, "pageSize=101 must be clamped to 100"); + } + + /// §P.4 boundary — pageSize=1000 → clamp to 100. + [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>(); + paged.Should().NotBeNull(); + paged!.PageSize.Should().Be(100, "pageSize=1000 must be clamped to max 100"); + } + + /// §P.5 boundary — page=-5 → clamp to 1. + [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>(); + paged.Should().NotBeNull(); + paged!.Page.Should().Be(1, "page=-5 must be clamped to 1"); + } + + /// §P.4 boundary — pageSize=0 → clamp to 1 (minimum). + [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>(); + paged.Should().NotBeNull(); + paged!.PageSize.Should().Be(1, "pageSize=0 must be clamped to minimum 1"); + } + /// §P.7 — Producto inexistente → 404. [Fact] public async Task GetPrices_ProductNotFound_Returns404() diff --git a/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs index e029c6e..215bd2c 100644 --- a/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs @@ -440,6 +440,77 @@ public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime 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 [Fact] public async Task GetActiveAsync_ExactBoundaryPvf_ReturnsRow()