From 6a9818b0ae3d4a09ab5b7c5c7206d248045d6b48 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 18:36:17 -0300 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20productPrices=20feature=20?= =?UTF-8?q?=E2=80=94=20history=20+=20dialog=20(PRD-003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API layer: getProductPrices + addProductPrice (axiosClient) - Hooks: useProductPrices (useQuery, staleTime 30s, enabled productId>0) useAddProductPrice (useMutation, invalidates ['products', id, 'prices']) - Components: ProductPriceHistory (shadcn Table + Badge Vigente, formatCivilDate, formatCurrency) AddProductPriceDialog (shadcn Dialog + Form, Zod schema con priceValidFrom>=todayArgentina()) - Integration: ProductsPage gets "Ver precios" per row opening prices dialog - lib/numberFormat.ts: formatCurrency() con Intl.NumberFormat ARS - types.ts extended: ProductPrice, AddProductPriceRequest, AddProductPriceResponse - Tests (Vitest + RTL): 19 tests — RED→GREEN confirmed - ProductPriceHistory: loading/error/empty/data/Badge Vigente/dialog/permissions - AddProductPriceDialog: validation (fecha pasada, precio=0, precio negativo), happy path payload + close, server 409 inline error, vi.useFakeTimers ART - hooks: useProductPrices caching + disabled when productId=0, useAddProductPrice invalidateQueries + error 409 - 453 total tests, 0 rojos --- .../features/products/api/addProductPrice.ts | 13 + .../features/products/api/getProductPrices.ts | 7 + .../components/AddProductPriceDialog.tsx | 206 ++++++++++++++ .../components/ProductPriceHistory.tsx | 114 ++++++++ .../products/hooks/useAddProductPrice.ts | 13 + .../products/hooks/useProductPrices.ts | 11 + src/web/src/features/products/index.ts | 3 + .../features/products/pages/ProductsPage.tsx | 46 +++- src/web/src/features/products/types.ts | 21 ++ src/web/src/lib/numberFormat.ts | 17 ++ .../products/AddProductPriceDialog.test.tsx | 253 ++++++++++++++++++ .../products/ProductPriceHistory.test.tsx | 199 ++++++++++++++ .../products/productPrices.hooks.test.ts | 104 +++++++ 13 files changed, 1003 insertions(+), 4 deletions(-) create mode 100644 src/web/src/features/products/api/addProductPrice.ts create mode 100644 src/web/src/features/products/api/getProductPrices.ts create mode 100644 src/web/src/features/products/components/AddProductPriceDialog.tsx create mode 100644 src/web/src/features/products/components/ProductPriceHistory.tsx create mode 100644 src/web/src/features/products/hooks/useAddProductPrice.ts create mode 100644 src/web/src/features/products/hooks/useProductPrices.ts create mode 100644 src/web/src/lib/numberFormat.ts create mode 100644 src/web/src/tests/features/products/AddProductPriceDialog.test.tsx create mode 100644 src/web/src/tests/features/products/ProductPriceHistory.test.tsx create mode 100644 src/web/src/tests/features/products/productPrices.hooks.test.ts diff --git a/src/web/src/features/products/api/addProductPrice.ts b/src/web/src/features/products/api/addProductPrice.ts new file mode 100644 index 0000000..a5da71a --- /dev/null +++ b/src/web/src/features/products/api/addProductPrice.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { AddProductPriceRequest, AddProductPriceResponse } from '../types' + +export async function addProductPrice( + productId: number, + payload: AddProductPriceRequest, +): Promise { + const res = await axiosClient.post( + `/api/v1/admin/products/${productId}/prices`, + payload, + ) + return res.data +} diff --git a/src/web/src/features/products/api/getProductPrices.ts b/src/web/src/features/products/api/getProductPrices.ts new file mode 100644 index 0000000..1f9c533 --- /dev/null +++ b/src/web/src/features/products/api/getProductPrices.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ProductPrice } from '../types' + +export async function getProductPrices(productId: number): Promise { + const res = await axiosClient.get(`/api/v1/products/${productId}/prices`) + return res.data +} diff --git a/src/web/src/features/products/components/AddProductPriceDialog.tsx b/src/web/src/features/products/components/AddProductPriceDialog.tsx new file mode 100644 index 0000000..36ced8c --- /dev/null +++ b/src/web/src/features/products/components/AddProductPriceDialog.tsx @@ -0,0 +1,206 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { todayArgentina } from '@/lib/dateFormat' +import { useAddProductPrice } from '../hooks/useAddProductPrice' + +// ─── Schema (Zod, espejo del backend) ──────────────────────────────────────── + +const addPriceSchema = z.object({ + price: z.coerce + .number('Debe ser un número') + .positive('El precio debe ser mayor a cero.'), + priceValidFrom: z + .string() + .min(1, 'La fecha es requerida.') + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.') + .refine( + (v) => v >= todayArgentina(), + 'La fecha no puede ser anterior a hoy.', + ), +}) + +type AddPriceFormRaw = { + price: string + priceValidFrom: string +} + +type AddPriceFormOutput = z.infer + +// ─── Error resolver ──────────────────────────────────────────────────────────── + +function resolveBackendError(err: unknown): string | null { + if (!err) return null + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { error?: string; message?: string } + if (err.response.status === 409) { + return data.message ?? 'La fecha debe ser posterior al precio vigente.' + } + if (err.response.status === 404) { + return data.message ?? 'Producto no encontrado.' + } + return data.message ?? data.error ?? 'Error al guardar el precio.' + } + return 'Error al guardar el precio. Intentá de nuevo.' +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface AddProductPriceDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + productId: number +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function AddProductPriceDialog({ + open, + onOpenChange, + productId, +}: AddProductPriceDialogProps) { + const mutation = useAddProductPrice(productId) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const form = useForm({ + resolver: zodResolver(addPriceSchema) as any, + defaultValues: { + price: '', + priceValidFrom: '', + }, + mode: 'onSubmit', + }) + + // Reset form and mutation state when dialog opens + useEffect(() => { + if (open) { + form.reset({ price: '', priceValidFrom: '' }) + mutation.reset() + } + }, [open]) // eslint-disable-line react-hooks/exhaustive-deps + + const backendError = resolveBackendError(mutation.error) + const today = todayArgentina() + + function handleSubmit(values: AddPriceFormOutput) { + mutation.mutate( + { price: values.price, priceValidFrom: values.priceValidFrom }, + { + onSuccess: () => { + onOpenChange(false) + }, + }, + ) + } + + return ( + + + + Programar nuevo precio + + Ingresá el nuevo precio y la fecha desde la que estará vigente. El + precio actual quedará cerrado. + + + +
+ [0], + )} + className="space-y-4" + noValidate + > + {backendError && ( + + + {backendError} + + )} + + {/* Precio */} + ( + + Precio + + + + + + )} + /> + + {/* Vigente desde */} + ( + + Vigente desde + + + + + + )} + /> + + + + + + + +
+
+ ) +} diff --git a/src/web/src/features/products/components/ProductPriceHistory.tsx b/src/web/src/features/products/components/ProductPriceHistory.tsx new file mode 100644 index 0000000..54eaaac --- /dev/null +++ b/src/web/src/features/products/components/ProductPriceHistory.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react' +import { AlertCircle, Plus } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { CanPerform } from '@/components/auth/CanPerform' +import { formatCivilDate } from '@/lib/dateFormat' +import { formatCurrency } from '@/lib/numberFormat' +import { useProductPrices } from '../hooks/useProductPrices' +import { AddProductPriceDialog } from './AddProductPriceDialog' + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface ProductPriceHistoryProps { + productId: number +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) { + const [addOpen, setAddOpen] = useState(false) + const { data: prices, isLoading, isError } = useProductPrices(productId) + + if (isLoading) { + return ( +
+ + + +
+ ) + } + + if (isError) { + return ( + + + Error al cargar precios del producto. + + ) + } + + const isEmpty = !prices?.length + + return ( +
+
+

Historial de precios

+ + + +
+ + {isEmpty ? ( +
+

Sin historial de precios. Este producto no tiene precios registrados.

+ + + +
+ ) : ( +
+ + + + Desde + Hasta + Precio + Estado + + + + {prices.map((p) => ( + + {formatCivilDate(p.priceValidFrom)} + + {p.priceValidTo ? formatCivilDate(p.priceValidTo) : '—'} + + {formatCurrency(p.price)} + + {p.isActive ? ( + Vigente + ) : null} + + + ))} + +
+
+ )} + + +
+ ) +} diff --git a/src/web/src/features/products/hooks/useAddProductPrice.ts b/src/web/src/features/products/hooks/useAddProductPrice.ts new file mode 100644 index 0000000..6dab471 --- /dev/null +++ b/src/web/src/features/products/hooks/useAddProductPrice.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { addProductPrice } from '../api/addProductPrice' +import type { AddProductPriceRequest } from '../types' + +export function useAddProductPrice(productId: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: AddProductPriceRequest) => addProductPrice(productId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['products', productId, 'prices'] }) + }, + }) +} diff --git a/src/web/src/features/products/hooks/useProductPrices.ts b/src/web/src/features/products/hooks/useProductPrices.ts new file mode 100644 index 0000000..0453bf4 --- /dev/null +++ b/src/web/src/features/products/hooks/useProductPrices.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { getProductPrices } from '../api/getProductPrices' + +export function useProductPrices(productId: number) { + return useQuery({ + queryKey: ['products', productId, 'prices'], + queryFn: () => getProductPrices(productId), + enabled: productId > 0, + staleTime: 30_000, + }) +} diff --git a/src/web/src/features/products/index.ts b/src/web/src/features/products/index.ts index a9f4708..98ba373 100644 --- a/src/web/src/features/products/index.ts +++ b/src/web/src/features/products/index.ts @@ -6,4 +6,7 @@ export type { UpdateProductRequest, PagedResult, ListProductsParams, + ProductPrice, + AddProductPriceRequest, + AddProductPriceResponse, } from './types' diff --git a/src/web/src/features/products/pages/ProductsPage.tsx b/src/web/src/features/products/pages/ProductsPage.tsx index 9512d85..acf961c 100644 --- a/src/web/src/features/products/pages/ProductsPage.tsx +++ b/src/web/src/features/products/pages/ProductsPage.tsx @@ -5,11 +5,18 @@ 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 { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { CanPerform } from '@/components/auth/CanPerform' import { useProducts } from '../hooks/useProducts' import { useDeactivateProduct } from '../hooks/useDeactivateProduct' import { ProductFormDialog } from '../components/ProductFormDialog' import { DeactivateProductDialog } from '../components/DeactivateProductDialog' +import { ProductPriceHistory } from '../components/ProductPriceHistory' import type { ProductListItem, ProductDetail } from '../types' const PAGE_SIZE = 20 @@ -26,6 +33,11 @@ export function ProductsPage() { const [deactivateOpen, setDeactivateOpen] = useState(false) const [deactivatingProduct, setDeactivatingProduct] = useState(null) + // ── Prices dialog state (PRD-003) ──────────────────────────────────────── + const [pricesOpen, setPricesOpen] = useState(false) + const [pricesProductId, setPricesProductId] = useState(null) + const [pricesProductName, setPricesProductName] = useState('') + // ── Pagination & filter state ──────────────────────────────────────────── const [page, setPage] = useState(1) const [medioIdFilter, setMedioIdFilter] = useState(undefined) @@ -59,6 +71,12 @@ export function ProductsPage() { setDeactivateOpen(true) } + function openPrices(p: ProductListItem) { + setPricesProductId(p.id) + setPricesProductName(p.nombre) + setPricesOpen(true) + } + async function handleDeactivate(id: number) { await deactivateProduct(id) toast.success('Producto desactivado') @@ -153,8 +171,16 @@ export function ProductsPage() { - -
+
+ + -
- + +
))} @@ -231,6 +257,18 @@ export function ProductsPage() { onConfirm={handleDeactivate} /> )} + + {/* Prices history dialog (PRD-003) */} + {pricesProductId !== null && ( + + + + Precios — {pricesProductName} + + + + + )} ) } diff --git a/src/web/src/features/products/types.ts b/src/web/src/features/products/types.ts index 5ff1369..036ca2e 100644 --- a/src/web/src/features/products/types.ts +++ b/src/web/src/features/products/types.ts @@ -56,3 +56,24 @@ export interface ListProductsParams { productTypeId?: number rubroId?: number } + +// PRD-003 — ProductPrices históricos + +export interface ProductPrice { + id: number + productId: number + price: number + priceValidFrom: string // "yyyy-MM-dd" (Cat2) + priceValidTo: string | null + isActive: boolean +} + +export interface AddProductPriceRequest { + price: number + priceValidFrom: string // "yyyy-MM-dd" +} + +export interface AddProductPriceResponse { + created: ProductPrice + closed: ProductPrice | null +} diff --git a/src/web/src/lib/numberFormat.ts b/src/web/src/lib/numberFormat.ts new file mode 100644 index 0000000..3e30a6b --- /dev/null +++ b/src/web/src/lib/numberFormat.ts @@ -0,0 +1,17 @@ +/** + * Formateo de números — utility centralizada. + * Usar SIEMPRE estas funciones en lugar de Intl.NumberFormat inline. + */ + +/** + * Formatea un número como moneda ARS (pesos argentinos). + * Output: "$ 1.500,50" o similar según locale. + */ +export function formatCurrency(amount: number): string { + return new Intl.NumberFormat('es-AR', { + style: 'currency', + currency: 'ARS', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount) +} diff --git a/src/web/src/tests/features/products/AddProductPriceDialog.test.tsx b/src/web/src/tests/features/products/AddProductPriceDialog.test.tsx new file mode 100644 index 0000000..6e0d1e5 --- /dev/null +++ b/src/web/src/tests/features/products/AddProductPriceDialog.test.tsx @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { AddProductPriceDialog } from '../../../features/products/components/AddProductPriceDialog' +import type { AddProductPriceResponse } from '../../../features/products/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +// ─── Server ─────────────────────────────────────────────────────────────────── + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() + vi.useRealTimers() +}) +afterAll(() => server.close()) + +// ─── Fake timers helper ─────────────────────────────────────────────────────── +// Fix "today" to 2026-04-19 ART (UTC-3). +// 2026-04-19T12:00:00-03:00 = 2026-04-19T15:00:00Z +// todayArgentina() uses Intl.DateTimeFormat with timeZone so this is stable. + +function setupFakeTimers() { + vi.useFakeTimers({ shouldAdvanceTime: true }) + vi.setSystemTime(new Date('2026-04-19T15:00:00.000Z')) +} + +// ─── Render helper ──────────────────────────────────────────────────────────── + +function renderDialog(onOpenChange = vi.fn()) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return { + qc, + result: render( + + + , + ), + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('AddProductPriceDialog — renders', () => { + it('renders dialog with price and date fields', () => { + renderDialog() + expect(screen.getByRole('dialog')).toBeInTheDocument() + // Use role spinbutton (number input) for price field + expect(screen.getByRole('spinbutton', { name: /precio$/i })).toBeInTheDocument() + expect(screen.getByLabelText(/vigente desde/i)).toBeInTheDocument() + }) +}) + +describe('AddProductPriceDialog — client-side validation', () => { + beforeEach(() => { + setupFakeTimers() + }) + + it('shows error when priceValidFrom is yesterday (2026-04-18 < 2026-04-19)', async () => { + renderDialog() + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '100') + + // "Yesterday" in ART = 2026-04-18 + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-18') + + const submitBtn = screen.getByRole('button', { name: /^guardar$/i }) + await userEvent.click(submitBtn) + + await waitFor( + () => expect(screen.getByText(/no puede ser anterior a hoy/i)).toBeInTheDocument(), + { timeout: 3000 }, + ) + }) + + it('accepts priceValidFrom = today (2026-04-19) as valid — no date error shown', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/products/1/prices`, () => + HttpResponse.json( + { + created: { id: 1, productId: 1, price: 100, priceValidFrom: '2026-04-19', priceValidTo: null, isActive: true }, + closed: null, + } satisfies AddProductPriceResponse, + { status: 201 }, + ), + ), + ) + + const onOpenChange = vi.fn() + renderDialog(onOpenChange) + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '100') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-19') + + const submitBtn = screen.getByRole('button', { name: /^guardar$/i }) + await userEvent.click(submitBtn) + + // Should NOT show the date validation error + await waitFor( + () => expect(screen.queryByText(/no puede ser anterior a hoy/i)).not.toBeInTheDocument(), + { timeout: 3000 }, + ) + }) + + it('shows error when price is 0', async () => { + renderDialog() + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '0') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + const submitBtn = screen.getByRole('button', { name: /^guardar$/i }) + await userEvent.click(submitBtn) + + await waitFor( + () => expect(screen.getByText(/debe ser mayor/i)).toBeInTheDocument(), + { timeout: 3000 }, + ) + }) + + it('shows error when price is negative', async () => { + renderDialog() + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '-50') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + const submitBtn = screen.getByRole('button', { name: /^guardar$/i }) + await userEvent.click(submitBtn) + + await waitFor( + () => expect(screen.getByText(/debe ser mayor/i)).toBeInTheDocument(), + { timeout: 3000 }, + ) + }) +}) + +describe('AddProductPriceDialog — happy path submit', () => { + beforeEach(() => { + setupFakeTimers() + }) + + it('calls mutation with correct payload and closes on success', async () => { + const mockResponse: AddProductPriceResponse = { + created: { + id: 2, + productId: 1, + price: 500.25, + priceValidFrom: '2026-04-25', + priceValidTo: null, + isActive: true, + }, + closed: null, + } + + let capturedBody: unknown = null + server.use( + http.post(`${API_URL}/api/v1/admin/products/1/prices`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockResponse, { status: 201 }) + }), + ) + + const onOpenChange = vi.fn() + renderDialog(onOpenChange) + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '500.25') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + await userEvent.click(screen.getByRole('button', { name: /^guardar$/i })) + + await waitFor( + () => expect(capturedBody).toEqual({ price: 500.25, priceValidFrom: '2026-04-25' }), + { timeout: 3000 }, + ) + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 3000 }) + }) +}) + +describe('AddProductPriceDialog — server error 409', () => { + beforeEach(() => { + setupFakeTimers() + }) + + it('shows inline message when server returns 409 (ForwardOnly)', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/products/1/prices`, () => + HttpResponse.json( + { + error: 'product_price_forward_only', + message: 'La fecha debe ser posterior al precio vigente', + }, + { status: 409 }, + ), + ), + ) + renderDialog() + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '100') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + await userEvent.click(screen.getByRole('button', { name: /^guardar$/i })) + + await waitFor( + () => expect(screen.getByText(/fecha debe ser posterior/i)).toBeInTheDocument(), + { timeout: 3000 }, + ) + }) +}) diff --git a/src/web/src/tests/features/products/ProductPriceHistory.test.tsx b/src/web/src/tests/features/products/ProductPriceHistory.test.tsx new file mode 100644 index 0000000..5ff48ee --- /dev/null +++ b/src/web/src/tests/features/products/ProductPriceHistory.test.tsx @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +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' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +// ─── Test data ──────────────────────────────────────────────────────────────── + +const mockActivePrice: ProductPrice = { + id: 2, + productId: 1, + price: 1500.50, + priceValidFrom: '2026-04-01', + priceValidTo: null, + isActive: true, +} + +const mockClosedPrice: ProductPrice = { + id: 1, + productId: 1, + price: 1000.00, + priceValidFrom: '2026-01-01', + priceValidTo: '2026-03-31', + isActive: false, +} + +const adminUser = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['catalogo:productos:gestionar'], + mustChangePassword: false, +} + +const regularUser = { + id: 2, + username: 'viewer', + nombre: 'Viewer', + rol: 'viewer', + permisos: [], + mustChangePassword: false, +} + +// ─── Server ─────────────────────────────────────────────────────────────────── + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +// ─── Render helper ──────────────────────────────────────────────────────────── + +function renderHistory(productId = 1, user = adminUser) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + , + ) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ProductPriceHistory — loading state', () => { + it('renders skeleton while loading', () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, async () => { + await new Promise(() => {}) + return HttpResponse.json([]) + }), + ) + renderHistory() + // Should show loading indicator + const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse') + expect(skeletons.length).toBeGreaterThan(0) + }) +}) + +describe('ProductPriceHistory — error state', () => { + it('renders error message on fetch failure', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json({ error: 'server_error' }, { status: 500 }), + ), + ) + renderHistory() + await waitFor(() => + expect(screen.getByText(/error al cargar precios/i)).toBeInTheDocument(), + ) + }) +}) + +describe('ProductPriceHistory — empty state', () => { + it('shows CTA when no prices exist', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([])), + ) + renderHistory() + await waitFor(() => + expect(screen.getByText(/sin historial de precios/i)).toBeInTheDocument(), + ) + // Should show at least one button to add first price (for users with permission) + // Both header button and empty-state CTA render in empty state + const addButtons = screen.getAllByRole('button', { name: /programar nuevo precio/i }) + expect(addButtons.length).toBeGreaterThan(0) + }) +}) + +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]), + ), + ) + renderHistory() + await waitFor(() => + expect(screen.getByText('01/04/2026')).toBeInTheDocument(), + ) + // Active price row visible + expect(screen.getByText('01/01/2026')).toBeInTheDocument() + // Closed price "hasta" date visible + expect(screen.getByText('31/03/2026')).toBeInTheDocument() + }) + + 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]), + ), + ) + renderHistory() + await waitFor(() => expect(screen.getByText('Vigente')).toBeInTheDocument()) + }) + + it('shows formatted currency for prices', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json([mockActivePrice]), + ), + ) + renderHistory() + // ARS currency format — 1500.50 + await waitFor(() => { + const cells = screen.getAllByRole('cell') + const hasCurrency = cells.some((c) => c.textContent?.includes('1.500') || c.textContent?.includes('1500')) + expect(hasCurrency).toBe(true) + }) + }) +}) + +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]), + ), + ) + renderHistory() + await waitFor(() => expect(screen.getByText('Vigente')).toBeInTheDocument()) + const btn = screen.getByRole('button', { name: /programar nuevo precio/i }) + await userEvent.click(btn) + // Dialog should open — check for dialog heading + await waitFor(() => + expect(screen.getByRole('dialog')).toBeInTheDocument(), + ) + }) + + 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]), + ), + ) + renderHistory(1, regularUser) + await waitFor(() => expect(screen.getByText('Vigente')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: /programar nuevo precio/i })).not.toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/products/productPrices.hooks.test.ts b/src/web/src/tests/features/products/productPrices.hooks.test.ts new file mode 100644 index 0000000..5eea52e --- /dev/null +++ b/src/web/src/tests/features/products/productPrices.hooks.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +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' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockPrice: ProductPrice = { + id: 1, + productId: 1, + price: 500, + priceValidFrom: '2026-04-01', + priceValidTo: null, + isActive: true, +} + +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 }, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function makeWrapper() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return { qc, wrapper: ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) } +} + +describe('useProductPrices', () => { + it('fetches prices for productId and returns data', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])), + ) + const { qc, 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() + }) + + it('is disabled when productId is 0', async () => { + // No server handler — if the query fired it would fail with unhandled request + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useProductPrices(0), { wrapper }) + // Should never enter loading/success + expect(result.current.isFetching).toBe(false) + expect(result.current.data).toBeUndefined() + }) +}) + +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.post(`${API_URL}/api/v1/admin/products/1/prices`, () => + HttpResponse.json(mockResponse, { status: 201 }), + ), + ) + const { qc, wrapper } = makeWrapper() + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + + const { result } = renderHook(() => useAddProductPrice(1), { wrapper }) + + await act(async () => { + result.current.mutate({ price: 700, priceValidFrom: '2026-05-01' }) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products', 1, 'prices'] }) + }) + + it('returns error state on 409', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/products/1/prices`, () => + HttpResponse.json({ error: 'product_price_forward_only' }, { status: 409 }), + ), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useAddProductPrice(1), { wrapper }) + await act(async () => { + result.current.mutate({ price: 100, priceValidFrom: '2026-04-19' }) + }) + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +})