From 0dce3ee4ac23c4db1465d64cc481a07aea39cb40 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 19:47:18 -0300 Subject: [PATCH 1/3] feat(api): pagination on GET product prices (closes #47) - GET /api/v1/products/{id}/prices now returns PagedResult 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 --- .../Controllers/ProductPricesController.cs | 27 ++- .../Persistence/IProductPriceRepository.cs | 10 +- .../SIGCM2.Application/DependencyInjection.cs | 2 +- .../AddPrice/AddProductPriceCommandHandler.cs | 5 +- .../GetHistory/GetProductPricesQuery.cs | 10 +- .../GetProductPricesQueryHandler.cs | 23 ++- .../Persistence/ProductPriceRepository.cs | 28 ++- .../Products/ProductPricesControllerTests.cs | 194 +++++++++++++++--- .../AddProductPriceCommandHandlerTests.cs | 24 ++- .../GetProductPricesQueryHandlerTests.cs | 108 +++++++--- .../ProductPriceRepositoryIntegrationTests.cs | 92 ++++++++- 11 files changed, 425 insertions(+), 98 deletions(-) diff --git a/src/api/SIGCM2.Api/Controllers/ProductPricesController.cs b/src/api/SIGCM2.Api/Controllers/ProductPricesController.cs index b3c8fbc..7376f8c 100644 --- a/src/api/SIGCM2.Api/Controllers/ProductPricesController.cs +++ b/src/api/SIGCM2.Api/Controllers/ProductPricesController.cs @@ -1,8 +1,8 @@ using FluentValidation; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SIGCM2.Api.Authorization; using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; using SIGCM2.Application.Products.Prices; using SIGCM2.Application.Products.Prices.AddPrice; using SIGCM2.Application.Products.Prices.GetHistory; @@ -11,7 +11,7 @@ namespace SIGCM2.Api.Controllers; /// /// PRD-003: ProductPrices historic pricing management. -/// Read endpoint at GET /api/v1/products/{id}/prices — requires authentication (any role). +/// Read endpoint at GET /api/v1/products/{id}/prices — requires 'catalogo:productos:gestionar'. /// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'. /// [ApiController] @@ -31,19 +31,28 @@ public sealed class ProductPricesController : ControllerBase // ── READ endpoint ────────────────────────────────────────────────────────── /// - /// Returns the full price history for a Product, ordered descending by PriceValidFrom. - /// Returns 200 with empty array if the product has no prices yet. + /// Returns a paginated page of price history for a Product, ordered descending by PriceValidFrom. + /// Defaults: page=1, pageSize=20. Clamping: page ≥ 1, pageSize ∈ [1, 100]. + /// Returns 200 with empty items if the product has no prices yet or page is beyond total. /// Returns 404 if the product does not exist. + /// Returns 401 if not authenticated, 403 if missing 'catalogo:productos:gestionar' permission. /// [HttpGet("api/v1/products/{id:int}/prices")] - [Authorize] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [RequirePermission("catalogo:productos:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetProductPrices([FromRoute] int id) + public async Task GetProductPrices( + [FromRoute] int id, + [FromQuery] int? page, + [FromQuery] int? pageSize) { - var query = new GetProductPricesQuery(id); - var result = await _dispatcher.Send>(query); + var query = new GetProductPricesQuery( + ProductId: id, + Page: page ?? 1, + PageSize: pageSize ?? 20); + var result = await _dispatcher.Send>(query); return Ok(result); } diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs index 0831183..1558b08 100644 --- a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs @@ -1,3 +1,4 @@ +using SIGCM2.Application.Common; using SIGCM2.Domain.Entities; namespace SIGCM2.Application.Abstractions.Persistence; @@ -21,11 +22,14 @@ public interface IProductPriceRepository CancellationToken ct = default); /// - /// Returns all price rows for the product, ordered descending by PriceValidFrom (active first). - /// Returns empty list when the product has no price history. + /// Returns a paginated page of price rows for the product, ordered descending by PriceValidFrom. + /// Caller is responsible for clamping page (≥ 1) and pageSize (1–100) before calling. + /// Returns PagedResult with empty Items when the product has no price history or page is beyond total. /// - Task> GetByProductIdAsync( + Task> GetByProductIdAsync( int productId, + int page, + int pageSize, CancellationToken ct = default); /// diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 98f5384..c5d7470 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -188,7 +188,7 @@ public static class DependencyInjection // ProductPrices (PRD-003) services.AddScoped, AddProductPriceCommandHandler>(); - services.AddScoped>, GetProductPricesQueryHandler>(); + services.AddScoped>, GetProductPricesQueryHandler>(); services.AddScoped(); // ProductTypes (PRD-001) diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs index d54ee9f..2cb8946 100644 --- a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs +++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs @@ -75,7 +75,10 @@ public sealed class AddProductPriceCommandHandler } // TX disposed (committed) here — BEFORE the post-commit read below. // 3. Compongo la respuesta post-commit con lectura de historial actualizado. - var prices = await _pricesRepo.GetByProductIdAsync(command.ProductId); + // La primera página (pageSize=2) es suficiente: solo necesitamos el nuevo y el cerrado, + // que son siempre los más recientes (ORDER BY PriceValidFrom DESC). + var pricesPage = await _pricesRepo.GetByProductIdAsync(command.ProductId, page: 1, pageSize: 2); + var prices = pricesPage.Items; var created = prices.Single(p => p.Id == newId); var closed = closedId.HasValue ? prices.SingleOrDefault(p => p.Id == closedId.Value) diff --git a/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs index 8dce568..d895edd 100644 --- a/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs +++ b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs @@ -1,8 +1,12 @@ namespace SIGCM2.Application.Products.Prices.GetHistory; /// -/// PRD-003 — Query para obtener el historial de precios de un Product. -/// Devuelve lista ordenada descending por PriceValidFrom (activo primero). +/// PRD-003 (paginated) — Query para obtener el historial de precios de un Product. +/// Devuelve PagedResult ordenado descending por PriceValidFrom (activo primero). /// Lanza ProductNotFoundException si el producto no existe. +/// Page y PageSize son clampeados por el handler: page ≥ 1, pageSize ∈ [1, 100]. /// -public sealed record GetProductPricesQuery(int ProductId); +public sealed record GetProductPricesQuery( + int ProductId, + int Page = 1, + int PageSize = 20); diff --git a/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs index c81e87c..2b39d3d 100644 --- a/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs +++ b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs @@ -1,18 +1,19 @@ using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; using SIGCM2.Application.Products.Prices; using SIGCM2.Domain.Exceptions; namespace SIGCM2.Application.Products.Prices.GetHistory; /// -/// PRD-003 — Handler de GetProductPricesQuery. -/// Verifica que el producto exista (404 si no), luego retorna historial de precios -/// ordenado descending por PriceValidFrom (responsabilidad del repo — SQL ORDER BY). -/// Lista vacía es válida (nuevo producto sin precios registrados aún). +/// PRD-003 (paginated) — Handler de GetProductPricesQuery. +/// Verifica que el producto exista (404 si no), aplica clamping defensivo de +/// page/pageSize y retorna PagedResult ordenado descending por PriceValidFrom. +/// Lista vacía es válida (nuevo producto sin precios o página más allá del total). /// public sealed class GetProductPricesQueryHandler - : ICommandHandler> + : ICommandHandler> { private readonly IProductPriceRepository _pricesRepo; private readonly IProductRepository _productsRepo; @@ -25,18 +26,24 @@ public sealed class GetProductPricesQueryHandler _productsRepo = productsRepo; } - public async Task> Handle(GetProductPricesQuery query) + public async Task> Handle(GetProductPricesQuery query) { // Verifica existencia del producto (lanza 404 si no existe). _ = await _productsRepo.GetByIdAsync(query.ProductId) ?? throw new ProductNotFoundException(query.ProductId); - var prices = await _pricesRepo.GetByProductIdAsync(query.ProductId); + // Clamping defensivo — igual al patrón de ListProductsQueryHandler. + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); - return prices + var paged = await _pricesRepo.GetByProductIdAsync(query.ProductId, page, pageSize); + + var dtoItems = paged.Items .Select(p => new ProductPriceDto( p.Id, p.ProductId, p.Price, p.PriceValidFrom, p.PriceValidTo, p.IsActive)) .ToList(); + + return new PagedResult(dtoItems, paged.Page, paged.PageSize, paged.Total); } } diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ProductPriceRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ProductPriceRepository.cs index 03621f7..c981628 100644 --- a/src/api/SIGCM2.Infrastructure/Persistence/ProductPriceRepository.cs +++ b/src/api/SIGCM2.Infrastructure/Persistence/ProductPriceRepository.cs @@ -2,6 +2,7 @@ using System.Data; using Dapper; using Microsoft.Data.SqlClient; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; @@ -70,25 +71,42 @@ public sealed class ProductPriceRepository : IProductPriceRepository } /// - public async Task> GetByProductIdAsync( + public async Task> GetByProductIdAsync( int productId, + int page, + int pageSize, CancellationToken ct = default) { // Uses IX_ProductPrices_Lookup (ProductId, PriceValidFrom DESC). - const string sql = """ + // Two separate queries on the same open connection: COUNT first, then paginated DATA. + const string countSql = """ + SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId + """; + + const string dataSql = """ SELECT Id, ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion FROM dbo.ProductPrices WHERE ProductId = @ProductId ORDER BY PriceValidFrom DESC, Id DESC + OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY """; + var offset = (page - 1) * pageSize; + await using var connection = _factory.CreateConnection(); await connection.OpenAsync(ct); - var rows = await connection.QueryAsync( - new CommandDefinition(sql, new { ProductId = productId }, cancellationToken: ct)); + var total = await connection.ExecuteScalarAsync( + new CommandDefinition(countSql, new { ProductId = productId }, cancellationToken: ct)); - return rows.Select(MapRow).ToList(); + var rows = await connection.QueryAsync( + new CommandDefinition(dataSql, + new { ProductId = productId, Offset = offset, PageSize = pageSize }, + cancellationToken: ct)); + + var items = rows.Select(MapRow).ToList(); + + return new PagedResult(items, page, pageSize, total); } /// diff --git a/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs b/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs index 132293f..cad6079 100644 --- a/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs @@ -7,6 +7,8 @@ using FluentAssertions; using Microsoft.Data.SqlClient; using Microsoft.Extensions.DependencyInjection; using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Common; +using SIGCM2.Application.Products.Prices; using SIGCM2.Domain.Entities; using SIGCM2.TestSupport; using Xunit; @@ -140,6 +142,7 @@ public sealed class ProductPricesControllerTests : IAsyncLifetime // ── GET /api/v1/products/{id}/prices ───────────────────────────────────── + /// §P.8 — No token → 401. [Fact] public async Task GetPrices_WithoutAuth_Returns401() { @@ -148,8 +151,20 @@ public sealed class ProductPricesControllerTests : IAsyncLifetime resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } + /// §P.8 — Token with no 'catalogo:productos:gestionar' → 403. [Fact] - public async Task GetPrices_EmptyHistory_Returns200WithEmptyArray() + public async Task GetPrices_WithoutPermission_Returns403() + { + var productId = await SeedProductAsync(); + var token = GetCajeroToken(); + using var req = BuildRequest(HttpMethod.Get, $"/api/v1/products/{productId}/prices", token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + /// §P.6 — Producto sin histórico → 200 con items=[], total=0, page=1, pageSize=20. + [Fact] + public async Task GetPrices_EmptyHistory_Returns200WithPagedResultEmpty() { var productId = await SeedProductAsync(); var token = GetAdminToken(); @@ -158,42 +173,138 @@ public sealed class ProductPricesControllerTests : IAsyncLifetime var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK); - var json = await resp.Content.ReadFromJsonAsync(); - json.ValueKind.Should().Be(JsonValueKind.Array); - json.GetArrayLength().Should().Be(0); + var paged = await resp.Content.ReadFromJsonAsync>(); + paged.Should().NotBeNull(); + paged!.Items.Should().BeEmpty(); + paged.Page.Should().Be(1); + paged.PageSize.Should().Be(20); + paged.Total.Should().Be(0); } + /// §P.1 — 10 precios, sin query params → defaults: page=1, pageSize=20, total=10, items=10. [Fact] - public async Task GetPrices_WithHistory_Returns200OrderedDescending() + public async Task GetPrices_TenPrices_NoParams_ReturnsDefaultsPagedResult() { var productId = await SeedProductAsync(); - - // Seed 3 prices: 2 closed + 1 active (in ascending order to verify API returns DESC) - await SeedPriceDirectAsync(productId, 50m, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)); - await SeedPriceDirectAsync(productId, 75m, new DateOnly(2026, 2, 1), new DateOnly(2026, 2, 28)); - await SeedPriceDirectAsync(productId, 100m, new DateOnly(2026, 3, 1), null); + // Seed 10 prices — all but the last have explicit PVT to respect UX_ProductPrices_Active + for (var i = 1; i <= 10; i++) + { + var pvt = i < 10 ? (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", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK); - var items = await resp.Content.ReadFromJsonAsync(); - items.GetArrayLength().Should().Be(3); - - // First item = most recent (active, March) - var first = items[0]; - first.GetProperty("priceValidFrom").GetString().Should().Be("2026-03-01"); - first.GetProperty("isActive").GetBoolean().Should().BeTrue(); - first.GetProperty("priceValidTo").ValueKind.Should().Be(JsonValueKind.Null); - - // Last item = oldest (January) - var last = items[2]; - last.GetProperty("priceValidFrom").GetString().Should().Be("2026-01-01"); - last.GetProperty("isActive").GetBoolean().Should().BeFalse(); - last.GetProperty("priceValidTo").GetString().Should().Be("2026-01-31"); + var paged = await resp.Content.ReadFromJsonAsync>(); + paged.Should().NotBeNull(); + paged!.Page.Should().Be(1); + paged.PageSize.Should().Be(20); + paged.Total.Should().Be(10); + paged.Items.Should().HaveCount(10); + // First item must be most recent (Jan 10) + paged.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 10)); } + /// §P.2 — 30 precios, page=2, pageSize=10 → items 11-20 ordenados DESC. + [Fact] + public async Task GetPrices_ThirtyPrices_Page2PageSize10_ReturnsCorrectPage() + { + var productId = await SeedProductAsync(); + // Seed 30 prices — all but the last have explicit PVT to respect UX_ProductPrices_Active + for (var i = 1; i <= 30; i++) + { + var pvt = i < 30 ? (DateOnly?)new DateOnly(2026, 1, i) : null; + await SeedPriceDirectAsync(productId, i * 5m, new DateOnly(2026, 1, i), pvt); + } + + var token = GetAdminToken(); + using var req = BuildRequest( + HttpMethod.Get, $"/api/v1/products/{productId}/prices?page=2&pageSize=10", 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(2); + paged.PageSize.Should().Be(10); + paged.Total.Should().Be(30); + paged.Items.Should().HaveCount(10); + // Ordered DESC by PVF: rank 11-20 from newest = Jan 20 down to Jan 11 + paged.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 20)); + paged.Items[9].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 11)); + } + + /// §P.3 — 30 precios, page=10, pageSize=10 → items=[], total=30. + [Fact] + public async Task GetPrices_ThirtyPrices_PageBeyondTotal_ReturnsEmptyItems() + { + var productId = await SeedProductAsync(); + for (var i = 1; i <= 30; i++) + { + var pvt = i < 30 ? (DateOnly?)new DateOnly(2026, 1, i) : null; + await SeedPriceDirectAsync(productId, i * 5m, new DateOnly(2026, 1, i), pvt); + } + + var token = GetAdminToken(); + using var req = BuildRequest( + HttpMethod.Get, $"/api/v1/products/{productId}/prices?page=10&pageSize=10", 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(10); + paged.PageSize.Should().Be(10); + paged.Total.Should().Be(30); + paged.Items.Should().BeEmpty(); + } + + /// §P.4 — pageSize=500 → clamp to 100 en la respuesta. + [Fact] + public async Task GetPrices_PageSizeOver100_ClampsTo100() + { + var productId = await SeedProductAsync(); + // Seed 5 prices — all but the last have explicit PVT to respect UX_ProductPrices_Active + for (var i = 1; i <= 5; i++) + { + var pvt = i < 5 ? (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=500", 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 must be clamped to max 100"); + paged.Items.Should().HaveCount(5); + } + + /// §P.5 — page=0 → clamp to 1. + [Fact] + public async Task GetPrices_PageZero_ClampsToOne() + { + var productId = await SeedProductAsync(); + await SeedPriceDirectAsync(productId, 100m, new DateOnly(2026, 1, 1), null); // single active row + + var token = GetAdminToken(); + using var req = BuildRequest( + HttpMethod.Get, $"/api/v1/products/{productId}/prices?page=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!.Page.Should().Be(1, "page=0 must be clamped to 1"); + } + + /// §P.7 — Producto inexistente → 404. [Fact] public async Task GetPrices_ProductNotFound_Returns404() { @@ -206,6 +317,41 @@ public sealed class ProductPricesControllerTests : IAsyncLifetime body.GetProperty("error").GetString().Should().Be("product_not_found"); } + /// + /// §P.1 compat — 3 prices, no params → PagedResult with items ordered DESC. + /// Replaces the old GetPrices_WithHistory_Returns200OrderedDescending. + /// + [Fact] + public async Task GetPrices_WithHistory_Returns200OrderedDescendingPaged() + { + var productId = await SeedProductAsync(); + + // Seed 3 prices: 2 closed + 1 active (ascending order to verify API returns DESC) + await SeedPriceDirectAsync(productId, 50m, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)); + await SeedPriceDirectAsync(productId, 75m, new DateOnly(2026, 2, 1), new DateOnly(2026, 2, 28)); + await SeedPriceDirectAsync(productId, 100m, new DateOnly(2026, 3, 1), null); + + var token = GetAdminToken(); + using var req = BuildRequest(HttpMethod.Get, $"/api/v1/products/{productId}/prices", token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.OK); + var paged = await resp.Content.ReadFromJsonAsync>(); + paged.Should().NotBeNull(); + paged!.Total.Should().Be(3); + paged.Items.Should().HaveCount(3); + + // First item = most recent (active, March) + paged.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 3, 1)); + paged.Items[0].IsActive.Should().BeTrue(); + paged.Items[0].PriceValidTo.Should().BeNull(); + + // Last item = oldest (January) + paged.Items[2].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 1)); + paged.Items[2].IsActive.Should().BeFalse(); + paged.Items[2].PriceValidTo.Should().Be(new DateOnly(2026, 1, 31)); + } + // ── POST /api/v1/admin/products/{id}/prices ─────────────────────────────── [Fact] diff --git a/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs index 78a4815..f3d783a 100644 --- a/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs @@ -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(), Arg.Any(), Arg.Any()) .Returns((10L, (long?)null)); - _pricesRepo.GetByProductIdAsync(1, Arg.Any()) - .Returns(new List - { - MakePrice(10, 1, 150m, Today) - }.AsReadOnly() as IReadOnlyList); + _pricesRepo.GetByProductIdAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PagedResult( + new List { 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(), Arg.Any(), Arg.Any()) .Returns((20L, (long?)5L)); // newId=20, closedId=5 - _pricesRepo.GetByProductIdAsync(1, Arg.Any()) - .Returns(new List - { - MakePrice(20, 1, 200m, Tomorrow), - MakePrice(5, 1, 150m, Today, pvt: Tomorrow.AddDays(-1)) - }.AsReadOnly() as IReadOnlyList); + _pricesRepo.GetByProductIdAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PagedResult( + new List + { + 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 }); diff --git a/tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs index 311bcca..8d546e1 100644 --- a/tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs @@ -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; /// -/// 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). /// 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 MakePagedResult( + IReadOnlyList items, int page = 1, int pageSize = 20, int? total = null) => + new(items, page, pageSize, total ?? items.Count); + public GetProductPricesQueryHandlerTests() { _productsRepo.GetByIdAsync(1, Arg.Any()) .Returns(ActiveProduct()); - // default: lista con 2 precios, el repo ya los devuelve descending (responsabilidad del repo) - _pricesRepo.GetByProductIdAsync(1, Arg.Any()) - .Returns(new List - { - 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); + // default: 3 precios devueltos descending por el repo + var defaultItems = new List + { + MakePrice(3, Date3), + MakePrice(2, Date2, Date3.AddDays(-1)), + MakePrice(1, Date1, Date2.AddDays(-1)) + }; + _pricesRepo + .GetByProductIdAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) + .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()) - .Returns(new List().AsReadOnly() as IReadOnlyList); + // §REQ-4.3 / §P.6 — nuevo producto aún no tiene precios → items vacíos (no 404) + _pricesRepo + .GetByProductIdAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PagedResult(new List(), 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()); + } + + [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()); + } + + [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()); + } + + [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()); } // ── Producto inexistente ──────────────────────────────────────────────────── @@ -108,6 +170,6 @@ public class GetProductPricesQueryHandlerTests await act.Should().ThrowAsync(); await _pricesRepo.DidNotReceive() - .GetByProductIdAsync(99, Arg.Any()); + .GetByProductIdAsync(99, Arg.Any(), Arg.Any(), Arg.Any()); } } diff --git a/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs index 6459f9e..e029c6e 100644 --- a/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs @@ -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 From 34b07a1d5580fd0f354ae26edf0a8b7b7dfd9054 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 19:52:45 -0300 Subject: [PATCH 2/3] feat(frontend): pagination UI on product prices history (refs #47) --- .../features/products/api/getProductPrices.ts | 13 +- .../components/ProductPriceHistory.tsx | 96 ++++++++---- .../products/hooks/useAddProductPrice.ts | 2 +- .../products/hooks/useProductPrices.ts | 9 +- .../products/ProductPriceHistory.test.tsx | 139 ++++++++++++++++-- .../products/productPrices.hooks.test.ts | 79 ++++++++-- 6 files changed, 282 insertions(+), 56 deletions(-) diff --git a/src/web/src/features/products/api/getProductPrices.ts b/src/web/src/features/products/api/getProductPrices.ts index 1f9c533..b169db7 100644 --- a/src/web/src/features/products/api/getProductPrices.ts +++ b/src/web/src/features/products/api/getProductPrices.ts @@ -1,7 +1,14 @@ import { axiosClient } from '@/api/axiosClient' -import type { ProductPrice } from '../types' +import type { PagedResult, ProductPrice } from '../types' -export async function getProductPrices(productId: number): Promise { - const res = await axiosClient.get(`/api/v1/products/${productId}/prices`) +export async function getProductPrices( + productId: number, + page: number = 1, + pageSize: number = 20, +): Promise> { + const res = await axiosClient.get>( + `/api/v1/products/${productId}/prices`, + { params: { page, pageSize } }, + ) return res.data } diff --git a/src/web/src/features/products/components/ProductPriceHistory.tsx b/src/web/src/features/products/components/ProductPriceHistory.tsx index 306f602..b02d520 100644 --- a/src/web/src/features/products/components/ProductPriceHistory.tsx +++ b/src/web/src/features/products/components/ProductPriceHistory.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { AlertCircle, Plus } from 'lucide-react' +import { AlertCircle, ChevronLeft, ChevronRight, Plus } from 'lucide-react' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -17,6 +17,10 @@ import { formatCivilDate, formatCurrency } from '@/lib/formatters' import { useProductPrices } from '../hooks/useProductPrices' import { AddProductPriceDialog } from './AddProductPriceDialog' +// ─── Constants ──────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 20 + // ─── Props ──────────────────────────────────────────────────────────────────── interface ProductPriceHistoryProps { @@ -27,7 +31,9 @@ interface ProductPriceHistoryProps { export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) { const [addOpen, setAddOpen] = useState(false) - const { data: prices, isLoading, isError } = useProductPrices(productId) + const [currentPage, setCurrentPage] = useState(1) + + const { data: prices, isLoading, isError } = useProductPrices(productId, currentPage, PAGE_SIZE) if (isLoading) { return ( @@ -48,7 +54,9 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) { ) } - const isEmpty = !prices?.length + const total = prices?.total ?? 0 + const totalPages = Math.ceil(total / PAGE_SIZE) + const isEmpty = total === 0 return (
@@ -73,34 +81,62 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
) : ( -
- - - - Desde - Hasta - Precio - Estado - - - - {prices.map((p) => ( - - {formatCivilDate(p.priceValidFrom)} - - {p.priceValidTo ? formatCivilDate(p.priceValidTo) : '—'} - - {formatCurrency(p.price)} - - {p.isActive ? ( - Vigente - ) : null} - + <> +
+
+ + + Desde + Hasta + Precio + Estado - ))} - -
-
+ + + {prices?.items.map((p) => ( + + {formatCivilDate(p.priceValidFrom)} + + {p.priceValidTo ? formatCivilDate(p.priceValidTo) : '—'} + + {formatCurrency(p.price)} + + {p.isActive ? ( + Vigente + ) : null} + + + ))} + + + + +
+ + Página {currentPage} de {totalPages || 1} + +
+ + +
+
+ )} addProductPrice(productId, payload), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['products', productId, 'prices'] }) + queryClient.invalidateQueries({ queryKey: ['product-prices', productId] }) }, }) } diff --git a/src/web/src/features/products/hooks/useProductPrices.ts b/src/web/src/features/products/hooks/useProductPrices.ts index 0453bf4..b1f137e 100644 --- a/src/web/src/features/products/hooks/useProductPrices.ts +++ b/src/web/src/features/products/hooks/useProductPrices.ts @@ -1,11 +1,12 @@ -import { useQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { getProductPrices } from '../api/getProductPrices' -export function useProductPrices(productId: number) { +export function useProductPrices(productId: number, page: number = 1, pageSize: number = 20) { return useQuery({ - queryKey: ['products', productId, 'prices'], - queryFn: () => getProductPrices(productId), + queryKey: ['product-prices', productId, page, pageSize], + queryFn: () => getProductPrices(productId, page, pageSize), enabled: productId > 0, staleTime: 30_000, + placeholderData: keepPreviousData, }) } diff --git a/src/web/src/tests/features/products/ProductPriceHistory.test.tsx b/src/web/src/tests/features/products/ProductPriceHistory.test.tsx index 5ff48ee..9dbf2fc 100644 --- a/src/web/src/tests/features/products/ProductPriceHistory.test.tsx +++ b/src/web/src/tests/features/products/ProductPriceHistory.test.tsx @@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' import { ProductPriceHistory } from '../../../features/products/components/ProductPriceHistory' import { useAuthStore } from '../../../stores/authStore' -import type { ProductPrice } from '../../../features/products/types' +import type { ProductPrice, PagedResult } from '../../../features/products/types' const API_URL = 'http://localhost:5000' @@ -53,6 +53,17 @@ const regularUser = { mustChangePassword: false, } +// ─── PagedResult helpers ────────────────────────────────────────────────────── + +function makePagedResult(items: ProductPrice[], opts: { page?: number; pageSize?: number; total?: number } = {}): PagedResult { + return { + items, + page: opts.page ?? 1, + pageSize: opts.pageSize ?? 20, + total: opts.total ?? items.length, + } +} + // ─── Server ─────────────────────────────────────────────────────────────────── const server = setupServer() @@ -86,7 +97,7 @@ describe('ProductPriceHistory — loading state', () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, async () => { await new Promise(() => {}) - return HttpResponse.json([]) + return HttpResponse.json(makePagedResult([])) }), ) renderHistory() @@ -111,9 +122,11 @@ describe('ProductPriceHistory — error state', () => { }) describe('ProductPriceHistory — empty state', () => { - it('shows CTA when no prices exist', async () => { + it('shows CTA when no prices exist (total=0)', async () => { server.use( - http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([])), + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json(makePagedResult([], { total: 0 })), + ), ) renderHistory() await waitFor(() => @@ -130,7 +143,7 @@ describe('ProductPriceHistory — data rendering', () => { it('renders price list with formatted dates and prices', async () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, () => - HttpResponse.json([mockActivePrice, mockClosedPrice]), + HttpResponse.json(makePagedResult([mockActivePrice, mockClosedPrice])), ), ) renderHistory() @@ -146,7 +159,7 @@ describe('ProductPriceHistory — data rendering', () => { it('shows Badge "Vigente" for active price row (priceValidTo=null)', async () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, () => - HttpResponse.json([mockActivePrice, mockClosedPrice]), + HttpResponse.json(makePagedResult([mockActivePrice, mockClosedPrice])), ), ) renderHistory() @@ -156,7 +169,7 @@ describe('ProductPriceHistory — data rendering', () => { it('shows formatted currency for prices', async () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, () => - HttpResponse.json([mockActivePrice]), + HttpResponse.json(makePagedResult([mockActivePrice])), ), ) renderHistory() @@ -173,7 +186,7 @@ describe('ProductPriceHistory — dialog integration', () => { it('opens AddProductPriceDialog when "Programar nuevo precio" is clicked', async () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, () => - HttpResponse.json([mockActivePrice]), + HttpResponse.json(makePagedResult([mockActivePrice])), ), ) renderHistory() @@ -189,7 +202,7 @@ describe('ProductPriceHistory — dialog integration', () => { it('hides "Programar nuevo precio" button when user lacks permission', async () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, () => - HttpResponse.json([mockActivePrice]), + HttpResponse.json(makePagedResult([mockActivePrice])), ), ) renderHistory(1, regularUser) @@ -197,3 +210,111 @@ describe('ProductPriceHistory — dialog integration', () => { expect(screen.queryByRole('button', { name: /programar nuevo precio/i })).not.toBeInTheDocument() }) }) + +// ─── §P.9 — §P.12: Pagination controls ─────────────────────────────────────── + +describe('ProductPriceHistory — pagination controls (§P.9–§P.12)', () => { + // §P.9: page 1 of many — Next enabled, Previous disabled + it('§P.9 — page 1: Next enabled and Previous disabled when total > pageSize', async () => { + // 30 total, pageSize=20 → 2 pages + const page1Items = Array.from({ length: 20 }, (_, i) => ({ + ...mockActivePrice, + id: i + 1, + isActive: i === 0, + })) + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json(makePagedResult(page1Items, { page: 1, pageSize: 20, total: 30 })), + ), + ) + renderHistory() + await waitFor(() => expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument()) + + const prevBtn = screen.getByRole('button', { name: /anterior/i }) + const nextBtn = screen.getByRole('button', { name: /siguiente/i }) + + expect(prevBtn).toBeDisabled() + expect(nextBtn).not.toBeDisabled() + }) + + // §P.10: click Next → refetch with page=2, renders new items + it('§P.10 — click Next → refetches page=2 and shows new items', async () => { + const price1 = { ...mockActivePrice, id: 1, price: 100, priceValidFrom: '2026-04-01', isActive: true } + const price2 = { ...mockActivePrice, id: 21, price: 200, priceValidFrom: '2026-01-01', priceValidTo: '2026-03-31', isActive: false } + + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => { + const url = new URL(request.url) + const page = url.searchParams.get('page') ?? '1' + if (page === '2') { + return HttpResponse.json(makePagedResult([price2], { page: 2, pageSize: 20, total: 21 })) + } + return HttpResponse.json(makePagedResult([price1], { page: 1, pageSize: 20, total: 21 })) + }), + ) + renderHistory() + + // Wait for page 1 to load — price1 has priceValidFrom 2026-04-01 + await waitFor(() => expect(screen.getByText('01/04/2026')).toBeInTheDocument()) + + const nextBtn = screen.getByRole('button', { name: /siguiente/i }) + await userEvent.click(nextBtn) + + // Page 2 shows price2 — priceValidFrom 2026-01-01 + await waitFor(() => expect(screen.getByText('01/01/2026')).toBeInTheDocument()) + }) + + // §P.11: first page always has Previous disabled + it('§P.11 — primera página: botón Anterior siempre deshabilitado', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json(makePagedResult([mockActivePrice], { page: 1, pageSize: 20, total: 1 })), + ), + ) + renderHistory() + await waitFor(() => expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled() + }) + + // §P.12: last page has Next disabled (page * pageSize >= total) + it('§P.12 — última página: botón Siguiente deshabilitado', async () => { + // page=2, pageSize=20, total=21 → 2 pages, page 2 is last + const lastPageItem = { ...mockClosedPrice, id: 21 } + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => { + const url = new URL(request.url) + const page = url.searchParams.get('page') ?? '1' + if (page === '2') { + return HttpResponse.json(makePagedResult([lastPageItem], { page: 2, pageSize: 20, total: 21 })) + } + // Page 1: 20 items + const items = Array.from({ length: 20 }, (_, i) => ({ ...mockClosedPrice, id: i + 1 })) + return HttpResponse.json(makePagedResult(items, { page: 1, pageSize: 20, total: 21 })) + }), + ) + renderHistory() + + // Navigate to page 2 + await waitFor(() => expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument()) + const nextBtn = screen.getByRole('button', { name: /siguiente/i }) + expect(nextBtn).not.toBeDisabled() + + await userEvent.click(nextBtn) + + // On page 2, Next should be disabled + await waitFor(() => expect(screen.getByRole('button', { name: /siguiente/i })).toBeDisabled()) + expect(screen.getByRole('button', { name: /anterior/i })).not.toBeDisabled() + }) + + it('shows page info text', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json(makePagedResult([mockActivePrice], { page: 1, pageSize: 20, total: 30 })), + ), + ) + renderHistory() + await waitFor(() => + expect(screen.getByText(/página 1 de 2/i)).toBeInTheDocument(), + ) + }) +}) diff --git a/src/web/src/tests/features/products/productPrices.hooks.test.ts b/src/web/src/tests/features/products/productPrices.hooks.test.ts index 5eea52e..dda690c 100644 --- a/src/web/src/tests/features/products/productPrices.hooks.test.ts +++ b/src/web/src/tests/features/products/productPrices.hooks.test.ts @@ -6,7 +6,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' import { useProductPrices } from '../../../features/products/hooks/useProductPrices' import { useAddProductPrice } from '../../../features/products/hooks/useAddProductPrice' -import type { ProductPrice, AddProductPriceResponse } from '../../../features/products/types' +import type { ProductPrice, AddProductPriceResponse, PagedResult } from '../../../features/products/types' const API_URL = 'http://localhost:5000' @@ -23,6 +23,13 @@ const mockPrice: ProductPrice = { isActive: true, } +const mockPagedResult: PagedResult = { + items: [mockPrice], + page: 1, + pageSize: 20, + total: 1, +} + const mockResponse: AddProductPriceResponse = { created: { id: 2, productId: 1, price: 700, priceValidFrom: '2026-05-01', priceValidTo: null, isActive: true }, closed: { id: 1, productId: 1, price: 500, priceValidFrom: '2026-04-01', priceValidTo: '2026-04-30', isActive: false }, @@ -46,16 +53,55 @@ function makeWrapper() { } describe('useProductPrices', () => { - it('fetches prices for productId and returns data', async () => { + it('fetches prices for productId and returns PagedResult data', async () => { server.use( - http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])), + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)), ) - const { qc, wrapper } = makeWrapper() + const { wrapper } = makeWrapper() const { result } = renderHook(() => useProductPrices(1), { wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(result.current.data).toEqual([mockPrice]) - // Verify caching: queryKey should be ['products', 1, 'prices'] - expect(qc.getQueryState(['products', 1, 'prices'])).toBeDefined() + expect(result.current.data).toEqual(mockPagedResult) + expect(result.current.data?.items).toEqual([mockPrice]) + }) + + it('includes page and pageSize in queryKey for correct caching', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)), + ) + const { qc, wrapper } = makeWrapper() + const { result } = renderHook(() => useProductPrices(1, 1, 20), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + // queryKey must include page and pageSize + expect(qc.getQueryState(['product-prices', 1, 1, 20])).toBeDefined() + }) + + it('uses different cache entry for different pages', async () => { + const page2Result: PagedResult = { + items: [{ ...mockPrice, id: 2 }], + page: 2, + pageSize: 20, + total: 21, + } + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => { + const url = new URL(request.url) + const page = url.searchParams.get('page') + return page === '2' ? HttpResponse.json(page2Result) : HttpResponse.json(mockPagedResult) + }), + ) + const { qc, wrapper } = makeWrapper() + + const { result: r1 } = renderHook(() => useProductPrices(1, 1, 20), { wrapper }) + await waitFor(() => expect(r1.current.isSuccess).toBe(true)) + + const { result: r2 } = renderHook(() => useProductPrices(1, 2, 20), { wrapper }) + await waitFor(() => expect(r2.current.isSuccess).toBe(true)) + + // Each page is cached separately + expect(qc.getQueryState(['product-prices', 1, 1, 20])).toBeDefined() + expect(qc.getQueryState(['product-prices', 1, 2, 20])).toBeDefined() + expect(r1.current.data?.page).toBe(1) + expect(r2.current.data?.page).toBe(2) }) it('is disabled when productId is 0', async () => { @@ -66,12 +112,27 @@ describe('useProductPrices', () => { expect(result.current.isFetching).toBe(false) expect(result.current.data).toBeUndefined() }) + + it('sends page and pageSize as query params in the URL', async () => { + let capturedUrl: string | null = null + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json(mockPagedResult) + }), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useProductPrices(1, 2, 10), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(capturedUrl).toContain('page=2') + expect(capturedUrl).toContain('pageSize=10') + }) }) describe('useAddProductPrice', () => { it('calls POST and invalidates product prices queries on success', async () => { server.use( - http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])), + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)), http.post(`${API_URL}/api/v1/admin/products/1/prices`, () => HttpResponse.json(mockResponse, { status: 201 }), ), @@ -85,7 +146,7 @@ describe('useAddProductPrice', () => { result.current.mutate({ price: 700, priceValidFrom: '2026-05-01' }) }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products', 1, 'prices'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-prices', 1] }) }) it('returns error state on 409', async () => { From e997409e954bf4014613446ac39ae2506e5f9486 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 20:01:09 -0300 Subject: [PATCH 3/3] test(integration): pagination edge cases (prd-003-prices-pagination) --- .../Products/ProductPricesControllerTests.cs | 96 +++++++++++++++++++ .../ProductPriceRepositoryIntegrationTests.cs | 71 ++++++++++++++ 2 files changed, 167 insertions(+) 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()