feat(api): pagination on GET product prices (closes #47)

- GET /api/v1/products/{id}/prices now returns PagedResult<ProductPriceDto>
  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
This commit is contained in:
2026-04-19 19:47:18 -03:00
parent da063ad677
commit 0dce3ee4ac
11 changed files with 425 additions and 98 deletions

View File

@@ -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 ─────────────────────────────────────
/// <summary>§P.8 — No token → 401.</summary>
[Fact]
public async Task GetPrices_WithoutAuth_Returns401()
{
@@ -148,8 +151,20 @@ public sealed class ProductPricesControllerTests : IAsyncLifetime
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
/// <summary>§P.8 — Token with no 'catalogo:productos:gestionar' → 403.</summary>
[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);
}
/// <summary>§P.6 — Producto sin histórico → 200 con items=[], total=0, page=1, pageSize=20.</summary>
[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<JsonElement>();
json.ValueKind.Should().Be(JsonValueKind.Array);
json.GetArrayLength().Should().Be(0);
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
paged.Should().NotBeNull();
paged!.Items.Should().BeEmpty();
paged.Page.Should().Be(1);
paged.PageSize.Should().Be(20);
paged.Total.Should().Be(0);
}
/// <summary>§P.1 — 10 precios, sin query params → defaults: page=1, pageSize=20, total=10, items=10.</summary>
[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<JsonElement>();
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<PagedResult<ProductPriceDto>>();
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));
}
/// <summary>§P.2 — 30 precios, page=2, pageSize=10 → items 11-20 ordenados DESC.</summary>
[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<PagedResult<ProductPriceDto>>();
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));
}
/// <summary>§P.3 — 30 precios, page=10, pageSize=10 → items=[], total=30.</summary>
[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<PagedResult<ProductPriceDto>>();
paged.Should().NotBeNull();
paged!.Page.Should().Be(10);
paged.PageSize.Should().Be(10);
paged.Total.Should().Be(30);
paged.Items.Should().BeEmpty();
}
/// <summary>§P.4 — pageSize=500 → clamp to 100 en la respuesta.</summary>
[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<PagedResult<ProductPriceDto>>();
paged.Should().NotBeNull();
paged!.PageSize.Should().Be(100, "pageSize must be clamped to max 100");
paged.Items.Should().HaveCount(5);
}
/// <summary>§P.5 — page=0 → clamp to 1.</summary>
[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<PagedResult<ProductPriceDto>>();
paged.Should().NotBeNull();
paged!.Page.Should().Be(1, "page=0 must be clamped to 1");
}
/// <summary>§P.7 — Producto inexistente → 404.</summary>
[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");
}
/// <summary>
/// §P.1 compat — 3 prices, no params → PagedResult with items ordered DESC.
/// Replaces the old GetPrices_WithHistory_Returns200OrderedDescending.
/// </summary>
[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<PagedResult<ProductPriceDto>>();
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]