From a7cfcdb68311d8a28b5e96a56348bb8a90eff131 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:36:48 -0300 Subject: [PATCH] test(frontend): ProductsPage pagination + filter tests (PRD-002 W5) --- .../features/products/pages/ProductsPage.tsx | 61 +++++++++++- .../features/products/ProductsPage.test.tsx | 96 +++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/src/web/src/features/products/pages/ProductsPage.tsx b/src/web/src/features/products/pages/ProductsPage.tsx index fcb9335..9512d85 100644 --- a/src/web/src/features/products/pages/ProductsPage.tsx +++ b/src/web/src/features/products/pages/ProductsPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { AlertCircle, Plus } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { Alert, AlertDescription } from '@/components/ui/alert' import { CanPerform } from '@/components/auth/CanPerform' @@ -11,6 +12,8 @@ import { ProductFormDialog } from '../components/ProductFormDialog' import { DeactivateProductDialog } from '../components/DeactivateProductDialog' import type { ProductListItem, ProductDetail } from '../types' +const PAGE_SIZE = 20 + export function ProductsPage() { // ── Create dialog state ────────────────────────────────────────────────── const [createOpen, setCreateOpen] = useState(false) @@ -23,7 +26,16 @@ export function ProductsPage() { const [deactivateOpen, setDeactivateOpen] = useState(false) const [deactivatingProduct, setDeactivatingProduct] = useState(null) - const { data: paged, isLoading, isError } = useProducts({ activo: true }) + // ── Pagination & filter state ──────────────────────────────────────────── + const [page, setPage] = useState(1) + const [medioIdFilter, setMedioIdFilter] = useState(undefined) + + const { data: paged, isLoading, isError } = useProducts({ + activo: true, + page, + pageSize: PAGE_SIZE, + medioId: medioIdFilter, + }) const { mutateAsync: deactivateProduct } = useDeactivateProduct() // ── Handlers ───────────────────────────────────────────────────────────── @@ -52,6 +64,14 @@ export function ProductsPage() { toast.success('Producto desactivado') } + function handleMedioFilterChange(e: React.ChangeEvent) { + const val = e.target.value + setPage(1) + setMedioIdFilter(val ? Number(val) : undefined) + } + + const totalPages = paged ? Math.ceil(paged.total / PAGE_SIZE) : 1 + // ── Loading / Error ─────────────────────────────────────────────────────── if (isLoading) { @@ -86,6 +106,18 @@ export function ProductsPage() { + {/* Filters */} +
+ +
+ {isEmpty ? (

No hay productos.

@@ -148,6 +180,33 @@ export function ProductsPage() {
)} + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Página {page} de {totalPages} + + +
+ )} + {/* Create dialog */} { ) }) }) + +// ─── Pagination ────────────────────────────────────────────────────────────── + +describe('ProductsPage — pagination', () => { + it('shows pagination controls when total > pageSize', async () => { + // 21 total items but only 1 in this page → totalPages=2 → controls visible + const pagedWith21Total: PagedResult = { + items: [mockItem], + page: 1, + pageSize: 20, + total: 21, + } + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(pagedWith21Total)), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument() + }) + + it('navigates to page 2 when Siguiente is clicked and sends page=2 to API', async () => { + const capturedRequests: URL[] = [] + + const pagedPage1: PagedResult = { + items: [mockItem], + page: 1, + pageSize: 20, + total: 21, + } + const pagedPage2: PagedResult = { + items: [{ ...mockItem, id: 2, nombre: 'Producto Página 2' }], + page: 2, + pageSize: 20, + total: 21, + } + + server.use( + http.get(`${API_URL}/api/v1/products`, ({ request }) => { + capturedRequests.push(new URL(request.url)) + const url = new URL(request.url) + const page = Number(url.searchParams.get('page') ?? '1') + return HttpResponse.json(page === 2 ? pagedPage2 : pagedPage1) + }), + ) + + renderPage(adminUser) + await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /siguiente/i })) + + await waitFor(() => { + expect(screen.getByText('Producto Página 2')).toBeInTheDocument() + }) + // Verify that at least one request was made with page=2 + const page2Requests = capturedRequests.filter((u) => u.searchParams.get('page') === '2') + expect(page2Requests.length).toBeGreaterThan(0) + }) +}) + +// ─── Filter by Medio ───────────────────────────────────────────────────────── + +describe('ProductsPage — filter by Medio', () => { + it('re-fetches with medioId when Medio filter is changed', async () => { + const capturedRequests: URL[] = [] + + const filteredPaged: PagedResult = { + items: [{ ...mockItem, medioId: 5, nombre: 'Producto Medio 5' }], + page: 1, + pageSize: 20, + total: 1, + } + + server.use( + http.get(`${API_URL}/api/v1/products`, ({ request }) => { + capturedRequests.push(new URL(request.url)) + const url = new URL(request.url) + const medioId = url.searchParams.get('medioId') + return HttpResponse.json(medioId === '5' ? filteredPaged : emptyPaged) + }), + ) + + renderPage(adminUser) + // Wait for initial empty state + await waitFor(() => expect(screen.getByText(/no hay productos/i)).toBeInTheDocument()) + + const filterInput = screen.getByLabelText(/filtrar por id de medio/i) + await userEvent.type(filterInput, '5') + + await waitFor(() => { + expect(screen.getByText('Producto Medio 5')).toBeInTheDocument() + }) + const filteredRequests = capturedRequests.filter((u) => u.searchParams.get('medioId') === '5') + expect(filteredRequests.length).toBeGreaterThan(0) + }) +})