Merge pull request 'feat: paginación en GET /api/v1/products/{id}/prices (closes #47)' (#51) from feature/prd-003-prices-pagination into main
This commit was merged in pull request #51.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
@@ -31,19 +31,28 @@ public sealed class ProductPricesController : ControllerBase
|
||||
// ── READ endpoint ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/products/{id:int}/prices")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ProductPriceDto>), StatusCodes.Status200OK)]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(typeof(PagedResult<ProductPriceDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetProductPrices([FromRoute] int id)
|
||||
public async Task<IActionResult> GetProductPrices(
|
||||
[FromRoute] int id,
|
||||
[FromQuery] int? page,
|
||||
[FromQuery] int? pageSize)
|
||||
{
|
||||
var query = new GetProductPricesQuery(id);
|
||||
var result = await _dispatcher.Send<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>(query);
|
||||
var query = new GetProductPricesQuery(
|
||||
ProductId: id,
|
||||
Page: page ?? 1,
|
||||
PageSize: pageSize ?? 20);
|
||||
var result = await _dispatcher.Send<GetProductPricesQuery, PagedResult<ProductPriceDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductPrice>> GetByProductIdAsync(
|
||||
Task<PagedResult<ProductPrice>> GetByProductIdAsync(
|
||||
int productId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -188,7 +188,7 @@ public static class DependencyInjection
|
||||
|
||||
// ProductPrices (PRD-003)
|
||||
services.AddScoped<ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>, AddProductPriceCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>, GetProductPricesQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>, GetProductPricesQueryHandler>();
|
||||
services.AddScoped<IProductPricingService, ProductPricingService>();
|
||||
|
||||
// ProductTypes (PRD-001)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
namespace SIGCM2.Application.Products.Prices.GetHistory;
|
||||
|
||||
/// <summary>
|
||||
/// 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].
|
||||
/// </summary>
|
||||
public sealed record GetProductPricesQuery(int ProductId);
|
||||
public sealed record GetProductPricesQuery(
|
||||
int ProductId,
|
||||
int Page = 1,
|
||||
int PageSize = 20);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public sealed class GetProductPricesQueryHandler
|
||||
: ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>
|
||||
: ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>
|
||||
{
|
||||
private readonly IProductPriceRepository _pricesRepo;
|
||||
private readonly IProductRepository _productsRepo;
|
||||
@@ -25,18 +26,24 @@ public sealed class GetProductPricesQueryHandler
|
||||
_productsRepo = productsRepo;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ProductPriceDto>> Handle(GetProductPricesQuery query)
|
||||
public async Task<PagedResult<ProductPriceDto>> 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<ProductPriceDto>(dtoItems, paged.Page, paged.PageSize, paged.Total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<ProductPrice>> GetByProductIdAsync(
|
||||
public async Task<PagedResult<ProductPrice>> 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<ProductPriceRow>(
|
||||
new CommandDefinition(sql, new { ProductId = productId }, cancellationToken: ct));
|
||||
var total = await connection.ExecuteScalarAsync<int>(
|
||||
new CommandDefinition(countSql, new { ProductId = productId }, cancellationToken: ct));
|
||||
|
||||
return rows.Select(MapRow).ToList();
|
||||
var rows = await connection.QueryAsync<ProductPriceRow>(
|
||||
new CommandDefinition(dataSql,
|
||||
new { ProductId = productId, Offset = offset, PageSize = pageSize },
|
||||
cancellationToken: ct));
|
||||
|
||||
var items = rows.Select(MapRow).ToList();
|
||||
|
||||
return new PagedResult<ProductPrice>(items, page, pageSize, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ProductPrice } from '../types'
|
||||
import type { PagedResult, ProductPrice } from '../types'
|
||||
|
||||
export async function getProductPrices(productId: number): Promise<ProductPrice[]> {
|
||||
const res = await axiosClient.get<ProductPrice[]>(`/api/v1/products/${productId}/prices`)
|
||||
export async function getProductPrices(
|
||||
productId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
): Promise<PagedResult<ProductPrice>> {
|
||||
const res = await axiosClient.get<PagedResult<ProductPrice>>(
|
||||
`/api/v1/products/${productId}/prices`,
|
||||
{ params: { page, pageSize } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, Plus } from 'lucide-react'
|
||||
import { AlertCircle, ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -17,6 +17,10 @@ import { formatCivilDate, formatCurrency } from '@/lib/formatters'
|
||||
import { useProductPrices } from '../hooks/useProductPrices'
|
||||
import { AddProductPriceDialog } from './AddProductPriceDialog'
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProductPriceHistoryProps {
|
||||
@@ -27,7 +31,9 @@ interface ProductPriceHistoryProps {
|
||||
|
||||
export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const { data: prices, isLoading, isError } = useProductPrices(productId)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
const { data: prices, isLoading, isError } = useProductPrices(productId, currentPage, PAGE_SIZE)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -48,7 +54,9 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const isEmpty = !prices?.length
|
||||
const total = prices?.total ?? 0
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
const isEmpty = total === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -73,6 +81,7 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
||||
</CanPerform>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -84,7 +93,7 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{prices.map((p) => (
|
||||
{prices?.items.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{formatCivilDate(p.priceValidFrom)}</TableCell>
|
||||
<TableCell>
|
||||
@@ -101,6 +110,33 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Página {currentPage} de {totalPages || 1}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setCurrentPage((p) => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= (totalPages || 1)}
|
||||
onClick={() => setCurrentPage((p) => p + 1)}
|
||||
>
|
||||
Siguiente
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AddProductPriceDialog
|
||||
|
||||
@@ -7,7 +7,7 @@ export function useAddProductPrice(productId: number) {
|
||||
return useMutation({
|
||||
mutationFn: (payload: AddProductPriceRequest) => addProductPrice(productId, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products', productId, 'prices'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['product-prices', productId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { getProductPrices } from '../api/getProductPrices'
|
||||
|
||||
export function useProductPrices(productId: number) {
|
||||
export function useProductPrices(productId: number, page: number = 1, pageSize: number = 20) {
|
||||
return useQuery({
|
||||
queryKey: ['products', productId, 'prices'],
|
||||
queryFn: () => getProductPrices(productId),
|
||||
queryKey: ['product-prices', productId, page, pageSize],
|
||||
queryFn: () => getProductPrices(productId, page, pageSize),
|
||||
enabled: productId > 0,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { ProductPriceHistory } from '../../../features/products/components/ProductPriceHistory'
|
||||
import { useAuthStore } from '../../../stores/authStore'
|
||||
import type { ProductPrice } from '../../../features/products/types'
|
||||
import type { ProductPrice, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
@@ -53,6 +53,17 @@ const regularUser = {
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
// ─── PagedResult helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function makePagedResult(items: ProductPrice[], opts: { page?: number; pageSize?: number; total?: number } = {}): PagedResult<ProductPrice> {
|
||||
return {
|
||||
items,
|
||||
page: opts.page ?? 1,
|
||||
pageSize: opts.pageSize ?? 20,
|
||||
total: opts.total ?? items.length,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const server = setupServer()
|
||||
@@ -86,7 +97,7 @@ describe('ProductPriceHistory — loading state', () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, async () => {
|
||||
await new Promise(() => {})
|
||||
return HttpResponse.json([])
|
||||
return HttpResponse.json(makePagedResult([]))
|
||||
}),
|
||||
)
|
||||
renderHistory()
|
||||
@@ -111,9 +122,11 @@ describe('ProductPriceHistory — error state', () => {
|
||||
})
|
||||
|
||||
describe('ProductPriceHistory — empty state', () => {
|
||||
it('shows CTA when no prices exist', async () => {
|
||||
it('shows CTA when no prices exist (total=0)', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([])),
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json(makePagedResult([], { total: 0 })),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
await waitFor(() =>
|
||||
@@ -130,7 +143,7 @@ describe('ProductPriceHistory — data rendering', () => {
|
||||
it('renders price list with formatted dates and prices', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json([mockActivePrice, mockClosedPrice]),
|
||||
HttpResponse.json(makePagedResult([mockActivePrice, mockClosedPrice])),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
@@ -146,7 +159,7 @@ describe('ProductPriceHistory — data rendering', () => {
|
||||
it('shows Badge "Vigente" for active price row (priceValidTo=null)', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json([mockActivePrice, mockClosedPrice]),
|
||||
HttpResponse.json(makePagedResult([mockActivePrice, mockClosedPrice])),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
@@ -156,7 +169,7 @@ describe('ProductPriceHistory — data rendering', () => {
|
||||
it('shows formatted currency for prices', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json([mockActivePrice]),
|
||||
HttpResponse.json(makePagedResult([mockActivePrice])),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
@@ -173,7 +186,7 @@ describe('ProductPriceHistory — dialog integration', () => {
|
||||
it('opens AddProductPriceDialog when "Programar nuevo precio" is clicked', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json([mockActivePrice]),
|
||||
HttpResponse.json(makePagedResult([mockActivePrice])),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
@@ -189,7 +202,7 @@ describe('ProductPriceHistory — dialog integration', () => {
|
||||
it('hides "Programar nuevo precio" button when user lacks permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json([mockActivePrice]),
|
||||
HttpResponse.json(makePagedResult([mockActivePrice])),
|
||||
),
|
||||
)
|
||||
renderHistory(1, regularUser)
|
||||
@@ -197,3 +210,111 @@ describe('ProductPriceHistory — dialog integration', () => {
|
||||
expect(screen.queryByRole('button', { name: /programar nuevo precio/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── §P.9 — §P.12: Pagination controls ───────────────────────────────────────
|
||||
|
||||
describe('ProductPriceHistory — pagination controls (§P.9–§P.12)', () => {
|
||||
// §P.9: page 1 of many — Next enabled, Previous disabled
|
||||
it('§P.9 — page 1: Next enabled and Previous disabled when total > pageSize', async () => {
|
||||
// 30 total, pageSize=20 → 2 pages
|
||||
const page1Items = Array.from({ length: 20 }, (_, i) => ({
|
||||
...mockActivePrice,
|
||||
id: i + 1,
|
||||
isActive: i === 0,
|
||||
}))
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json(makePagedResult(page1Items, { page: 1, pageSize: 20, total: 30 })),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument())
|
||||
|
||||
const prevBtn = screen.getByRole('button', { name: /anterior/i })
|
||||
const nextBtn = screen.getByRole('button', { name: /siguiente/i })
|
||||
|
||||
expect(prevBtn).toBeDisabled()
|
||||
expect(nextBtn).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// §P.10: click Next → refetch with page=2, renders new items
|
||||
it('§P.10 — click Next → refetches page=2 and shows new items', async () => {
|
||||
const price1 = { ...mockActivePrice, id: 1, price: 100, priceValidFrom: '2026-04-01', isActive: true }
|
||||
const price2 = { ...mockActivePrice, id: 21, price: 200, priceValidFrom: '2026-01-01', priceValidTo: '2026-03-31', isActive: false }
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const page = url.searchParams.get('page') ?? '1'
|
||||
if (page === '2') {
|
||||
return HttpResponse.json(makePagedResult([price2], { page: 2, pageSize: 20, total: 21 }))
|
||||
}
|
||||
return HttpResponse.json(makePagedResult([price1], { page: 1, pageSize: 20, total: 21 }))
|
||||
}),
|
||||
)
|
||||
renderHistory()
|
||||
|
||||
// Wait for page 1 to load — price1 has priceValidFrom 2026-04-01
|
||||
await waitFor(() => expect(screen.getByText('01/04/2026')).toBeInTheDocument())
|
||||
|
||||
const nextBtn = screen.getByRole('button', { name: /siguiente/i })
|
||||
await userEvent.click(nextBtn)
|
||||
|
||||
// Page 2 shows price2 — priceValidFrom 2026-01-01
|
||||
await waitFor(() => expect(screen.getByText('01/01/2026')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
// §P.11: first page always has Previous disabled
|
||||
it('§P.11 — primera página: botón Anterior siempre deshabilitado', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json(makePagedResult([mockActivePrice], { page: 1, pageSize: 20, total: 1 })),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
// §P.12: last page has Next disabled (page * pageSize >= total)
|
||||
it('§P.12 — última página: botón Siguiente deshabilitado', async () => {
|
||||
// page=2, pageSize=20, total=21 → 2 pages, page 2 is last
|
||||
const lastPageItem = { ...mockClosedPrice, id: 21 }
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const page = url.searchParams.get('page') ?? '1'
|
||||
if (page === '2') {
|
||||
return HttpResponse.json(makePagedResult([lastPageItem], { page: 2, pageSize: 20, total: 21 }))
|
||||
}
|
||||
// Page 1: 20 items
|
||||
const items = Array.from({ length: 20 }, (_, i) => ({ ...mockClosedPrice, id: i + 1 }))
|
||||
return HttpResponse.json(makePagedResult(items, { page: 1, pageSize: 20, total: 21 }))
|
||||
}),
|
||||
)
|
||||
renderHistory()
|
||||
|
||||
// Navigate to page 2
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument())
|
||||
const nextBtn = screen.getByRole('button', { name: /siguiente/i })
|
||||
expect(nextBtn).not.toBeDisabled()
|
||||
|
||||
await userEvent.click(nextBtn)
|
||||
|
||||
// On page 2, Next should be disabled
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /siguiente/i })).toBeDisabled())
|
||||
expect(screen.getByRole('button', { name: /anterior/i })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows page info text', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json(makePagedResult([mockActivePrice], { page: 1, pageSize: 20, total: 30 })),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/página 1 de 2/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useProductPrices } from '../../../features/products/hooks/useProductPrices'
|
||||
import { useAddProductPrice } from '../../../features/products/hooks/useAddProductPrice'
|
||||
import type { ProductPrice, AddProductPriceResponse } from '../../../features/products/types'
|
||||
import type { ProductPrice, AddProductPriceResponse, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
@@ -23,6 +23,13 @@ const mockPrice: ProductPrice = {
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockPagedResult: PagedResult<ProductPrice> = {
|
||||
items: [mockPrice],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const mockResponse: AddProductPriceResponse = {
|
||||
created: { id: 2, productId: 1, price: 700, priceValidFrom: '2026-05-01', priceValidTo: null, isActive: true },
|
||||
closed: { id: 1, productId: 1, price: 500, priceValidFrom: '2026-04-01', priceValidTo: '2026-04-30', isActive: false },
|
||||
@@ -46,16 +53,55 @@ function makeWrapper() {
|
||||
}
|
||||
|
||||
describe('useProductPrices', () => {
|
||||
it('fetches prices for productId and returns data', async () => {
|
||||
it('fetches prices for productId and returns PagedResult data', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])),
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)),
|
||||
)
|
||||
const { qc, wrapper } = makeWrapper()
|
||||
const { wrapper } = makeWrapper()
|
||||
const { result } = renderHook(() => useProductPrices(1), { wrapper })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual([mockPrice])
|
||||
// Verify caching: queryKey should be ['products', 1, 'prices']
|
||||
expect(qc.getQueryState(['products', 1, 'prices'])).toBeDefined()
|
||||
expect(result.current.data).toEqual(mockPagedResult)
|
||||
expect(result.current.data?.items).toEqual([mockPrice])
|
||||
})
|
||||
|
||||
it('includes page and pageSize in queryKey for correct caching', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)),
|
||||
)
|
||||
const { qc, wrapper } = makeWrapper()
|
||||
const { result } = renderHook(() => useProductPrices(1, 1, 20), { wrapper })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
// queryKey must include page and pageSize
|
||||
expect(qc.getQueryState(['product-prices', 1, 1, 20])).toBeDefined()
|
||||
})
|
||||
|
||||
it('uses different cache entry for different pages', async () => {
|
||||
const page2Result: PagedResult<ProductPrice> = {
|
||||
items: [{ ...mockPrice, id: 2 }],
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
total: 21,
|
||||
}
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const page = url.searchParams.get('page')
|
||||
return page === '2' ? HttpResponse.json(page2Result) : HttpResponse.json(mockPagedResult)
|
||||
}),
|
||||
)
|
||||
const { qc, wrapper } = makeWrapper()
|
||||
|
||||
const { result: r1 } = renderHook(() => useProductPrices(1, 1, 20), { wrapper })
|
||||
await waitFor(() => expect(r1.current.isSuccess).toBe(true))
|
||||
|
||||
const { result: r2 } = renderHook(() => useProductPrices(1, 2, 20), { wrapper })
|
||||
await waitFor(() => expect(r2.current.isSuccess).toBe(true))
|
||||
|
||||
// Each page is cached separately
|
||||
expect(qc.getQueryState(['product-prices', 1, 1, 20])).toBeDefined()
|
||||
expect(qc.getQueryState(['product-prices', 1, 2, 20])).toBeDefined()
|
||||
expect(r1.current.data?.page).toBe(1)
|
||||
expect(r2.current.data?.page).toBe(2)
|
||||
})
|
||||
|
||||
it('is disabled when productId is 0', async () => {
|
||||
@@ -66,12 +112,27 @@ describe('useProductPrices', () => {
|
||||
expect(result.current.isFetching).toBe(false)
|
||||
expect(result.current.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sends page and pageSize as query params in the URL', async () => {
|
||||
let capturedUrl: string | null = null
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
|
||||
capturedUrl = request.url
|
||||
return HttpResponse.json(mockPagedResult)
|
||||
}),
|
||||
)
|
||||
const { wrapper } = makeWrapper()
|
||||
const { result } = renderHook(() => useProductPrices(1, 2, 10), { wrapper })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(capturedUrl).toContain('page=2')
|
||||
expect(capturedUrl).toContain('pageSize=10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAddProductPrice', () => {
|
||||
it('calls POST and invalidates product prices queries on success', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])),
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)),
|
||||
http.post(`${API_URL}/api/v1/admin/products/1/prices`, () =>
|
||||
HttpResponse.json(mockResponse, { status: 201 }),
|
||||
),
|
||||
@@ -85,7 +146,7 @@ describe('useAddProductPrice', () => {
|
||||
result.current.mutate({ price: 700, priceValidFrom: '2026-05-01' })
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products', 1, 'prices'] })
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-prices', 1] })
|
||||
})
|
||||
|
||||
it('returns error state on 409', async () => {
|
||||
|
||||
@@ -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,234 @@ 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.4 boundary — pageSize=100 exacto → no clamping, boundary inclusivo.</summary>
|
||||
[Fact]
|
||||
public async Task GetPrices_PageSize100_Exact_Returns200()
|
||||
{
|
||||
var productId = await SeedProductAsync();
|
||||
// Seed 3 prices — all but the last have explicit PVT
|
||||
for (var i = 1; i <= 3; i++)
|
||||
{
|
||||
var pvt = i < 3 ? (DateOnly?)new DateOnly(2026, 1, i) : null;
|
||||
await SeedPriceDirectAsync(productId, i * 10m, new DateOnly(2026, 1, i), pvt);
|
||||
}
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(
|
||||
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=100", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||
paged.Should().NotBeNull();
|
||||
paged!.PageSize.Should().Be(100, "pageSize=100 is the upper boundary — must NOT be clamped further");
|
||||
paged.Items.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
/// <summary>§P.4 boundary — pageSize=101 → clamp to 100.</summary>
|
||||
[Fact]
|
||||
public async Task GetPrices_PageSize101_ClampsTo100()
|
||||
{
|
||||
var productId = await SeedProductAsync();
|
||||
await SeedPriceDirectAsync(productId, 50m, new DateOnly(2026, 2, 1), null);
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(
|
||||
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=101", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||
paged.Should().NotBeNull();
|
||||
paged!.PageSize.Should().Be(100, "pageSize=101 must be clamped to 100");
|
||||
}
|
||||
|
||||
/// <summary>§P.4 boundary — pageSize=1000 → clamp to 100.</summary>
|
||||
[Fact]
|
||||
public async Task GetPrices_PageSize1000_ClampsTo100()
|
||||
{
|
||||
var productId = await SeedProductAsync();
|
||||
await SeedPriceDirectAsync(productId, 75m, new DateOnly(2026, 3, 1), null);
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(
|
||||
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=1000", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||
paged.Should().NotBeNull();
|
||||
paged!.PageSize.Should().Be(100, "pageSize=1000 must be clamped to max 100");
|
||||
}
|
||||
|
||||
/// <summary>§P.5 boundary — page=-5 → clamp to 1.</summary>
|
||||
[Fact]
|
||||
public async Task GetPrices_PageNegative_ClampsToOne()
|
||||
{
|
||||
var productId = await SeedProductAsync();
|
||||
await SeedPriceDirectAsync(productId, 120m, new DateOnly(2026, 4, 1), null);
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(
|
||||
HttpMethod.Get, $"/api/v1/products/{productId}/prices?page=-5", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||
paged.Should().NotBeNull();
|
||||
paged!.Page.Should().Be(1, "page=-5 must be clamped to 1");
|
||||
}
|
||||
|
||||
/// <summary>§P.4 boundary — pageSize=0 → clamp to 1 (minimum).</summary>
|
||||
[Fact]
|
||||
public async Task GetPrices_PageSizeZero_ClampsToOne()
|
||||
{
|
||||
var productId = await SeedProductAsync();
|
||||
await SeedPriceDirectAsync(productId, 90m, new DateOnly(2026, 5, 1), null);
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(
|
||||
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=0", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||
paged.Should().NotBeNull();
|
||||
paged!.PageSize.Should().Be(1, "pageSize=0 must be clamped to minimum 1");
|
||||
}
|
||||
|
||||
/// <summary>§P.7 — Producto inexistente → 404.</summary>
|
||||
[Fact]
|
||||
public async Task GetPrices_ProductNotFound_Returns404()
|
||||
{
|
||||
@@ -206,6 +413,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]
|
||||
|
||||
@@ -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<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
|
||||
.Returns((10L, (long?)null));
|
||||
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ProductPrice>
|
||||
{
|
||||
MakePrice(10, 1, 150m, Today)
|
||||
}.AsReadOnly() as IReadOnlyList<ProductPrice>);
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<ProductPrice>(
|
||||
new List<ProductPrice> { 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<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
|
||||
.Returns((20L, (long?)5L)); // newId=20, closedId=5
|
||||
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ProductPrice>
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<ProductPrice>(
|
||||
new List<ProductPrice>
|
||||
{
|
||||
MakePrice(20, 1, 200m, Tomorrow),
|
||||
MakePrice(5, 1, 150m, Today, pvt: Tomorrow.AddDays(-1))
|
||||
}.AsReadOnly() as IReadOnlyList<ProductPrice>);
|
||||
},
|
||||
1, 2, 2));
|
||||
|
||||
var result = await _handler.Handle(ValidCmd() with { Price = 200m, PriceValidFrom = Tomorrow });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<ProductPrice> MakePagedResult(
|
||||
IReadOnlyList<ProductPrice> items, int page = 1, int pageSize = 20, int? total = null) =>
|
||||
new(items, page, pageSize, total ?? items.Count);
|
||||
|
||||
public GetProductPricesQueryHandlerTests()
|
||||
{
|
||||
_productsRepo.GetByIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(ActiveProduct());
|
||||
|
||||
// default: lista con 2 precios, el repo ya los devuelve descending (responsabilidad del repo)
|
||||
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ProductPrice>
|
||||
// default: 3 precios devueltos descending por el repo
|
||||
var defaultItems = new List<ProductPrice>
|
||||
{
|
||||
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<ProductPrice>);
|
||||
MakePrice(3, Date3),
|
||||
MakePrice(2, Date2, Date3.AddDays(-1)),
|
||||
MakePrice(1, Date1, Date2.AddDays(-1))
|
||||
};
|
||||
_pricesRepo
|
||||
.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.Returns(new List<ProductPrice>().AsReadOnly() as IReadOnlyList<ProductPrice>);
|
||||
// §REQ-4.3 / §P.6 — nuevo producto aún no tiene precios → items vacíos (no 404)
|
||||
_pricesRepo
|
||||
.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<ProductPrice>(new List<ProductPrice>(), 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<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Producto inexistente ────────────────────────────────────────────────────
|
||||
@@ -108,6 +170,6 @@ public class GetProductPricesQueryHandlerTests
|
||||
await act.Should().ThrowAsync<ProductNotFoundException>();
|
||||
|
||||
await _pricesRepo.DidNotReceive()
|
||||
.GetByProductIdAsync(99, Arg.Any<CancellationToken>());
|
||||
.GetByProductIdAsync(99, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,164 @@ 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);
|
||||
}
|
||||
|
||||
// §B3.2 — 30 prices: page 1 and page 2 (pageSize=10) are fully disjoint,
|
||||
// union = 20 distinct items, each page is ordered DESC by PriceValidFrom.
|
||||
[Fact]
|
||||
public async Task GetByProductIdAsync_ThirtyPrices_TwoPages_AreDisjointAndOrderedDesc()
|
||||
{
|
||||
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await seedConn.OpenAsync();
|
||||
|
||||
// Insert 30 prices directly (bypass SP to avoid forward-only complexity).
|
||||
// All rows get an explicit PriceValidTo so no unique-index violation.
|
||||
// PVF: 2026-01-01 to 2026-01-30 (ascending), all closed (PVT = PVF + 1 day).
|
||||
// Except row 30 which has PVT = NULL (the single active row).
|
||||
for (var i = 1; i <= 30; i++)
|
||||
{
|
||||
DateTime? pvt = i < 30
|
||||
? (DateTime?)new DateTime(2026, 1, i + 1)
|
||||
: null;
|
||||
|
||||
await seedConn.ExecuteAsync("""
|
||||
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion)
|
||||
VALUES (@ProductId, @Price, @PriceValidFrom, @PriceValidTo, SYSUTCDATETIME())
|
||||
""",
|
||||
new
|
||||
{
|
||||
ProductId = _defaultProductId,
|
||||
Price = (decimal)(i * 10),
|
||||
PriceValidFrom = new DateTime(2026, 1, i),
|
||||
PriceValidTo = pvt
|
||||
});
|
||||
}
|
||||
|
||||
var repo = BuildRepository();
|
||||
|
||||
var page1 = await repo.GetByProductIdAsync(_defaultProductId, page: 1, pageSize: 10);
|
||||
var page2 = await repo.GetByProductIdAsync(_defaultProductId, page: 2, pageSize: 10);
|
||||
|
||||
// Both pages must reflect the full dataset
|
||||
page1.Total.Should().Be(30);
|
||||
page2.Total.Should().Be(30);
|
||||
|
||||
// Each page must have exactly 10 items
|
||||
page1.Items.Should().HaveCount(10);
|
||||
page2.Items.Should().HaveCount(10);
|
||||
|
||||
// Page meta
|
||||
page1.Page.Should().Be(1);
|
||||
page1.PageSize.Should().Be(10);
|
||||
page2.Page.Should().Be(2);
|
||||
page2.PageSize.Should().Be(10);
|
||||
|
||||
// The two ID sets must be fully disjoint
|
||||
var ids1 = page1.Items.Select(p => p.PriceValidFrom).ToHashSet();
|
||||
var ids2 = page2.Items.Select(p => p.PriceValidFrom).ToHashSet();
|
||||
ids1.Intersect(ids2).Should().BeEmpty("pages must not share any items");
|
||||
|
||||
// Union covers exactly 20 distinct items
|
||||
ids1.Union(ids2).Should().HaveCount(20, "union of two pages of 10 must yield 20 distinct items");
|
||||
|
||||
// Page 1 must be ordered DESC — first item is PVF Jan 30 (most recent)
|
||||
page1.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 30),
|
||||
"page 1 rank-1 must be the most recent price (DESC)");
|
||||
page1.Items[9].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 21),
|
||||
"page 1 rank-10 must be Jan 21");
|
||||
|
||||
// Page 2 must continue DESC — first item is PVF Jan 20
|
||||
page2.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 20),
|
||||
"page 2 rank-1 must be Jan 20 (rank 11 globally in DESC order)");
|
||||
page2.Items[9].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 11),
|
||||
"page 2 rank-10 must be Jan 11");
|
||||
}
|
||||
|
||||
// §REQ-4.4 — GetActiveAsync: exact boundary PriceValidFrom = query date → returns row
|
||||
|
||||
Reference in New Issue
Block a user