From 0dce3ee4ac23c4db1465d64cc481a07aea39cb40 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 19:47:18 -0300 Subject: [PATCH] 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