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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user