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