diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 3331aa9..aa89dcf 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -17,6 +17,7 @@ import { Store, Tag, Layers, + Package, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -82,6 +83,12 @@ const adminItems: NavItem[] = [ icon: Layers, requiredPermission: 'catalogo:tipos:gestionar', }, + { + label: 'Productos', + href: '/admin/products', + icon: Package, + requiredPermission: 'catalogo:productos:gestionar', + }, ] interface SidebarNavProps { diff --git a/src/web/src/features/products/api/createProduct.ts b/src/web/src/features/products/api/createProduct.ts new file mode 100644 index 0000000..845bcfb --- /dev/null +++ b/src/web/src/features/products/api/createProduct.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '@/api/axiosClient' +import type { CreateProductRequest, ProductDetail } from '../types' + +export async function createProduct( + payload: CreateProductRequest, +): Promise { + const response = await axiosClient.post( + '/api/v1/admin/products', + payload, + ) + return response.data +} diff --git a/src/web/src/features/products/api/deactivateProduct.ts b/src/web/src/features/products/api/deactivateProduct.ts new file mode 100644 index 0000000..1522a70 --- /dev/null +++ b/src/web/src/features/products/api/deactivateProduct.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateProduct(id: number): Promise { + await axiosClient.delete(`/api/v1/admin/products/${id}`) +} diff --git a/src/web/src/features/products/api/getProductById.ts b/src/web/src/features/products/api/getProductById.ts new file mode 100644 index 0000000..039d7c2 --- /dev/null +++ b/src/web/src/features/products/api/getProductById.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ProductDetail } from '../types' + +export async function getProductById(id: number): Promise { + const response = await axiosClient.get(`/api/v1/products/${id}`) + return response.data +} diff --git a/src/web/src/features/products/api/listProducts.ts b/src/web/src/features/products/api/listProducts.ts new file mode 100644 index 0000000..5fbd8d6 --- /dev/null +++ b/src/web/src/features/products/api/listProducts.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ListProductsParams, PagedResult, ProductListItem } from '../types' + +export async function listProducts( + params?: ListProductsParams, +): Promise> { + const response = await axiosClient.get>( + '/api/v1/products', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/products/api/updateProduct.ts b/src/web/src/features/products/api/updateProduct.ts new file mode 100644 index 0000000..b626359 --- /dev/null +++ b/src/web/src/features/products/api/updateProduct.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UpdateProductRequest, ProductDetail } from '../types' + +export async function updateProduct( + id: number, + payload: UpdateProductRequest, +): Promise { + const response = await axiosClient.put( + `/api/v1/admin/products/${id}`, + payload, + ) + return response.data +} diff --git a/src/web/src/features/products/components/DeactivateProductDialog.tsx b/src/web/src/features/products/components/DeactivateProductDialog.tsx new file mode 100644 index 0000000..4a39ae1 --- /dev/null +++ b/src/web/src/features/products/components/DeactivateProductDialog.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' +import { AlertCircle } from 'lucide-react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Alert, AlertDescription } from '@/components/ui/alert' +import type { ProductListItem } from '../types' + +// ─── Error resolver ──────────────────────────────────────────────────────────── + +function resolveDeactivateError(err: unknown): string | null { + if (!err) return null + const errObj = err as { response?: { status?: number; data?: { message?: string } } } + if (errObj?.response?.data?.message) { + return errObj.response.data.message + } + return 'Error al desactivar el producto' +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface DeactivateProductDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + product: ProductListItem + onConfirm: (id: number) => Promise | void +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function DeactivateProductDialog({ + open, + onOpenChange, + product, + onConfirm, +}: DeactivateProductDialogProps) { + const [error, setError] = useState(null) + const [isPending, setIsPending] = useState(false) + + async function handleConfirm() { + setError(null) + setIsPending(true) + try { + await onConfirm(product.id) + onOpenChange(false) + } catch (err) { + setError(resolveDeactivateError(err)) + } finally { + setIsPending(false) + } + } + + return ( + + + + Desactivar producto + + ¿Desactivar el producto “{product.nombre}”? El producto no + aparecerá en los listados activos. + + + + {error && ( + + + {error} + + )} + + + Cancelar + + {isPending ? 'Procesando...' : 'Desactivar'} + + + + + ) +} diff --git a/src/web/src/features/products/components/ProductForm.tsx b/src/web/src/features/products/components/ProductForm.tsx new file mode 100644 index 0000000..df48dab --- /dev/null +++ b/src/web/src/features/products/components/ProductForm.tsx @@ -0,0 +1,280 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' + +// ─── Schema ─────────────────────────────────────────────────────────────────── + +function nullablePositiveInt() { + return z + .string() + .optional() + .transform((v) => (v === '' || v == null ? null : Number(v))) + .pipe(z.number().int().positive().nullable()) +} + +const productFormSchema = z.object({ + nombre: z.string().trim().min(1, 'Nombre requerido').max(300, 'Máximo 300 caracteres'), + medioId: z + .string() + .min(1, 'Medio requerido') + .transform((v) => Number(v)) + .pipe(z.number().int().positive('Medio requerido')), + productTypeId: z + .string() + .min(1, 'Tipo de producto requerido') + .transform((v) => Number(v)) + .pipe(z.number().int().positive('Tipo de producto requerido')), + rubroId: nullablePositiveInt(), + basePrice: z + .string() + .min(1, 'Precio requerido') + .transform((v) => Number(v)) + .pipe(z.number().min(0, 'El precio no puede ser negativo')), + priceDurationDays: nullablePositiveInt(), +}) + +// Raw form field types (strings before zod transforms) +type ProductFormRaw = { + nombre: string + medioId: string + productTypeId: string + rubroId: string + basePrice: string + priceDurationDays: string +} + +// Output type after zod transforms (what onSubmit receives at runtime) +export type ProductFormOutput = { + nombre: string + medioId: number + productTypeId: number + rubroId: number | null + basePrice: number + priceDurationDays: number | null +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface ProductFormDefaultValues { + nombre?: string + medioId?: number | null + productTypeId?: number | null + rubroId?: number | null + basePrice?: number | null + priceDurationDays?: number | null +} + +interface ProductFormProps { + defaultValues?: ProductFormDefaultValues + onSubmit: (values: ProductFormOutput) => void + onCancel: () => void + isPending?: boolean + isEdit?: boolean +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ProductForm({ + defaultValues, + onSubmit, + onCancel, + isPending = false, + isEdit = false, +}: ProductFormProps) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const form = useForm({ + resolver: zodResolver(productFormSchema) as any, + defaultValues: { + nombre: defaultValues?.nombre ?? '', + medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '', + productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '', + rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '', + basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '', + priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '', + }, + }) + + useEffect(() => { + form.reset({ + nombre: defaultValues?.nombre ?? '', + medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '', + productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '', + rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '', + basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '', + priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '', + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultValues?.nombre, defaultValues?.medioId, defaultValues?.productTypeId]) + + function handleSubmit(data: ProductFormOutput) { + onSubmit(data) + } + + return ( +
+ [0], + )} + className="space-y-4" + noValidate + > + {/* Nombre */} + ( + + Nombre + + + + + + )} + /> + + {/* Medio ID */} + ( + + ID de Medio + + + + + + )} + /> + + {/* Product Type ID */} + ( + + ID de Tipo de Producto + + + + + + )} + /> + + {/* Rubro ID (optional) */} + ( + + ID de Rubro (opcional) + + + + + + )} + /> + + {/* Base Price */} + ( + + Precio base + + + + + + )} + /> + + {/* Price Duration Days (optional) */} + ( + + Días de duración del precio (opcional) + + + + + + )} + /> + +
+ + +
+ + + ) +} diff --git a/src/web/src/features/products/components/ProductFormDialog.tsx b/src/web/src/features/products/components/ProductFormDialog.tsx new file mode 100644 index 0000000..1f53d49 --- /dev/null +++ b/src/web/src/features/products/components/ProductFormDialog.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { ProductForm } from './ProductForm' +import type { ProductFormOutput } from './ProductForm' +import { useCreateProduct } from '../hooks/useCreateProduct' +import { useUpdateProduct } from '../hooks/useUpdateProduct' +import type { ProductDetail } from '../types' + +// ─── 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 } + return data.message ?? data.error ?? 'Error al guardar el producto' + } + const errObj = err as { response?: { data?: { message?: string } } } + if (errObj?.response?.data?.message) { + return errObj.response.data.message + } + return 'Error al guardar el producto' +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface ProductFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + product?: ProductDetail + onSuccess?: () => void +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ProductFormDialog({ + open, + onOpenChange, + product, + onSuccess, +}: ProductFormDialogProps) { + const [backendError, setBackendError] = useState(null) + + const isEdit = !!product + const { mutateAsync: createProduct, isPending: creating } = useCreateProduct() + const { mutateAsync: updateProduct, isPending: updating } = useUpdateProduct() + const isPending = creating || updating + + async function handleSubmit(values: ProductFormOutput) { + setBackendError(null) + try { + if (isEdit) { + await updateProduct({ + id: product.id, + data: { + nombre: values.nombre, + rubroId: values.rubroId, + basePrice: values.basePrice, + priceDurationDays: values.priceDurationDays, + }, + }) + toast.success('Producto actualizado') + } else { + await createProduct({ + nombre: values.nombre, + medioId: values.medioId, + productTypeId: values.productTypeId, + rubroId: values.rubroId, + basePrice: values.basePrice, + priceDurationDays: values.priceDurationDays, + }) + toast.success('Producto creado') + } + onOpenChange(false) + onSuccess?.() + } catch (err) { + const msg = resolveBackendError(err) + setBackendError(msg) + if ( + !isAxiosError(err) || + (err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400) + ) { + toast.error(isEdit ? 'Error al actualizar producto' : 'Error al crear producto') + } + } + } + + return ( + + + + {isEdit ? 'Editar producto' : 'Nuevo producto'} + + {isEdit + ? `Modificá los datos del producto "${product?.nombre ?? ''}".` + : 'Completá los datos para crear un nuevo producto.'} + + + + {backendError && ( + + + {backendError} + + )} + + onOpenChange(false)} + isPending={isPending} + isEdit={isEdit} + /> + + + ) +} diff --git a/src/web/src/features/products/hooks/useCreateProduct.ts b/src/web/src/features/products/hooks/useCreateProduct.ts new file mode 100644 index 0000000..f8648f1 --- /dev/null +++ b/src/web/src/features/products/hooks/useCreateProduct.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createProduct } from '../api/createProduct' +import type { CreateProductRequest } from '../types' + +export function useCreateProduct() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateProductRequest) => createProduct(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['products'] }) + }, + }) +} diff --git a/src/web/src/features/products/hooks/useDeactivateProduct.ts b/src/web/src/features/products/hooks/useDeactivateProduct.ts new file mode 100644 index 0000000..221e39d --- /dev/null +++ b/src/web/src/features/products/hooks/useDeactivateProduct.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateProduct } from '../api/deactivateProduct' + +export function useDeactivateProduct() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deactivateProduct(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['products'] }) + }, + }) +} diff --git a/src/web/src/features/products/hooks/useProducts.ts b/src/web/src/features/products/hooks/useProducts.ts new file mode 100644 index 0000000..bec5e76 --- /dev/null +++ b/src/web/src/features/products/hooks/useProducts.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { listProducts } from '../api/listProducts' +import type { ListProductsParams } from '../types' + +export function useProducts(params?: ListProductsParams) { + return useQuery({ + queryKey: ['products', params], + queryFn: () => listProducts(params), + }) +} diff --git a/src/web/src/features/products/hooks/useUpdateProduct.ts b/src/web/src/features/products/hooks/useUpdateProduct.ts new file mode 100644 index 0000000..4caa075 --- /dev/null +++ b/src/web/src/features/products/hooks/useUpdateProduct.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateProduct } from '../api/updateProduct' +import type { UpdateProductRequest } from '../types' + +export function useUpdateProduct() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateProductRequest }) => + updateProduct(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['products'] }) + }, + }) +} diff --git a/src/web/src/features/products/index.ts b/src/web/src/features/products/index.ts new file mode 100644 index 0000000..a9f4708 --- /dev/null +++ b/src/web/src/features/products/index.ts @@ -0,0 +1,9 @@ +export { ProductsPage } from './pages/ProductsPage' +export type { + ProductListItem, + ProductDetail, + CreateProductRequest, + UpdateProductRequest, + PagedResult, + ListProductsParams, +} from './types' diff --git a/src/web/src/features/products/pages/ProductsPage.tsx b/src/web/src/features/products/pages/ProductsPage.tsx new file mode 100644 index 0000000..fcb9335 --- /dev/null +++ b/src/web/src/features/products/pages/ProductsPage.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react' +import { AlertCircle, Plus } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Alert, AlertDescription } from '@/components/ui/alert' +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 type { ProductListItem, ProductDetail } from '../types' + +export function ProductsPage() { + // ── Create dialog state ────────────────────────────────────────────────── + const [createOpen, setCreateOpen] = useState(false) + + // ── Edit dialog state ──────────────────────────────────────────────────── + const [editOpen, setEditOpen] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) + + // ── Deactivate dialog state ────────────────────────────────────────────── + const [deactivateOpen, setDeactivateOpen] = useState(false) + const [deactivatingProduct, setDeactivatingProduct] = useState(null) + + const { data: paged, isLoading, isError } = useProducts({ activo: true }) + const { mutateAsync: deactivateProduct } = useDeactivateProduct() + + // ── Handlers ───────────────────────────────────────────────────────────── + + function openCreate() { + setCreateOpen(true) + } + + function openEdit(p: ProductListItem) { + const detail: ProductDetail = { + ...p, + fechaCreacion: '', + fechaModificacion: null, + } + setEditingProduct(detail) + setEditOpen(true) + } + + function openDeactivate(p: ProductListItem) { + setDeactivatingProduct(p) + setDeactivateOpen(true) + } + + async function handleDeactivate(id: number) { + await deactivateProduct(id) + toast.success('Producto desactivado') + } + + // ── Loading / Error ─────────────────────────────────────────────────────── + + if (isLoading) { + return ( +
+ + +
+ ) + } + + if (isError) { + return ( + + + Error al cargar productos. + + ) + } + + const isEmpty = !paged?.items.length + + return ( +
+
+

Productos

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

No hay productos.

+ + + +
+ ) : ( +
+ + + + + + + + + + + + {paged.items.map((p: ProductListItem) => ( + + + + + + + + + ))} + +
NombreMedioTipoPrecio baseActivo +
{p.nombre}{p.medioId}{p.productTypeId}{p.basePrice} + + {p.isActive ? 'Activo' : 'Inactivo'} + + + +
+ + +
+
+
+
+ )} + + {/* Create dialog */} + + + {/* Edit dialog */} + {editingProduct && ( + + )} + + {/* Deactivate confirmation dialog */} + {deactivatingProduct && ( + + )} +
+ ) +} diff --git a/src/web/src/features/products/types.ts b/src/web/src/features/products/types.ts new file mode 100644 index 0000000..5ff1369 --- /dev/null +++ b/src/web/src/features/products/types.ts @@ -0,0 +1,58 @@ +// PRD-002 — shared types for products feature + +export interface ProductListItem { + id: number + nombre: string + medioId: number + productTypeId: number + rubroId: number | null + basePrice: number + priceDurationDays: number | null + isActive: boolean +} + +export interface ProductDetail { + id: number + nombre: string + medioId: number + productTypeId: number + rubroId: number | null + basePrice: number + priceDurationDays: number | null + isActive: boolean + fechaCreacion: string + fechaModificacion: string | null +} + +export interface CreateProductRequest { + nombre: string + medioId: number + productTypeId: number + rubroId?: number | null + basePrice: number + priceDurationDays?: number | null +} + +export interface UpdateProductRequest { + nombre: string + rubroId?: number | null + basePrice: number + priceDurationDays?: number | null +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} + +export interface ListProductsParams { + page?: number + pageSize?: number + activo?: boolean | null + search?: string + medioId?: number + productTypeId?: number + rubroId?: number +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 992cc76..6ce3ea5 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -29,6 +29,7 @@ import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage' import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage' import { RubrosPage } from './features/rubros/pages/RubrosPage' import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage' +import { ProductsPage } from './features/products/pages/ProductsPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -320,6 +321,16 @@ export function AppRoutes() { } /> + {/* Products routes — PRD-002 */} + + + + } + /> + } /> ) diff --git a/src/web/src/tests/features/products/ProductsPage.test.tsx b/src/web/src/tests/features/products/ProductsPage.test.tsx new file mode 100644 index 0000000..0fdd1a3 --- /dev/null +++ b/src/web/src/tests/features/products/ProductsPage.test.tsx @@ -0,0 +1,187 @@ +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 { MemoryRouter, Routes, Route } from 'react-router-dom' +import React from 'react' +import { ProductsPage } from '../../../features/products/pages/ProductsPage' +import { useAuthStore } from '../../../stores/authStore' +import type { ProductListItem, PagedResult } from '../../../features/products/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +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, +} + +const mockItem: ProductListItem = { + id: 1, + nombre: 'Clasificado Estándar', + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 100.50, + priceDurationDays: null, + isActive: true, +} + +const mockPaged: PagedResult = { + items: [mockItem], + page: 1, + pageSize: 20, + total: 1, +} + +const emptyPaged: PagedResult = { + items: [], + page: 1, + pageSize: 20, + total: 0, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(user = adminUser) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +// ─── Loading / Error / Data states ────────────────────────────────────────── + +describe('ProductsPage — loading and error states', () => { + it('renders loading skeleton while fetching', () => { + server.use( + http.get(`${API_URL}/api/v1/products`, async () => { + await new Promise(() => {}) + return HttpResponse.json(emptyPaged) + }), + ) + renderPage() + const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse') + expect(skeletons.length).toBeGreaterThan(0) + }) + + it('renders data when loaded', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)), + ) + renderPage() + await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument()) + }) + + it('shows error state on fetch failure', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => + HttpResponse.json({ error: 'server_error' }, { status: 500 }), + ), + ) + renderPage() + await waitFor(() => + expect(screen.getByText(/error al cargar productos/i)).toBeInTheDocument(), + ) + }) + + it('shows empty state when no products', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)), + ) + renderPage() + await waitFor(() => + expect(screen.getByText(/no hay productos/i)).toBeInTheDocument(), + ) + }) +}) + +// ─── Permission gating ─────────────────────────────────────────────────────── + +describe('ProductsPage — permission gating', () => { + it('shows "Nuevo Producto" button when user has permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)), + ) + renderPage(adminUser) + await waitFor(() => + expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument(), + ) + }) + + it('hides "Nuevo Producto" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)), + ) + renderPage(regularUser) + await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: /nuevo producto/i })).not.toBeInTheDocument() + }) +}) + +// ─── Create dialog ─────────────────────────────────────────────────────────── + +describe('ProductsPage — create dialog', () => { + it('opens create dialog when "Nuevo Producto" button is clicked', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /nuevo producto/i })) + await waitFor(() => + expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument(), + ) + }) +}) + +// ─── Deactivate dialog ─────────────────────────────────────────────────────── + +describe('ProductsPage — deactivate dialog', () => { + it('opens deactivate confirmation dialog when Desactivar is clicked', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument(), + ) + }) +}) diff --git a/src/web/src/tests/features/products/api.test.ts b/src/web/src/tests/features/products/api.test.ts new file mode 100644 index 0000000..65bd7bd --- /dev/null +++ b/src/web/src/tests/features/products/api.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { listProducts } from '../../../features/products/api/listProducts' +import { getProductById } from '../../../features/products/api/getProductById' +import { createProduct } from '../../../features/products/api/createProduct' +import { updateProduct } from '../../../features/products/api/updateProduct' +import { deactivateProduct } from '../../../features/products/api/deactivateProduct' +import type { ProductListItem, ProductDetail, PagedResult } from '../../../features/products/types' + +const API_URL = 'http://localhost:5000' + +const mockListItem: ProductListItem = { + id: 1, + nombre: 'Clasificado Estándar', + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 100.50, + priceDurationDays: null, + isActive: true, +} + +const mockDetail: ProductDetail = { + id: 1, + nombre: 'Clasificado Estándar', + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 100.50, + priceDurationDays: null, + isActive: true, + fechaCreacion: '2026-04-19T00:00:00Z', + fechaModificacion: null, +} + +const mockPaged: PagedResult = { + items: [mockListItem], + page: 1, + pageSize: 20, + total: 1, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('listProducts', () => { + it('calls GET /api/v1/products and returns paged result', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)), + ) + const result = await listProducts() + expect(result).toEqual(mockPaged) + }) + + it('passes query params when provided', async () => { + let capturedUrl = '' + server.use( + http.get(`${API_URL}/api/v1/products`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json(mockPaged) + }), + ) + await listProducts({ page: 2, pageSize: 10, activo: true, medioId: 5 }) + expect(capturedUrl).toContain('page=2') + expect(capturedUrl).toContain('pageSize=10') + expect(capturedUrl).toContain('medioId=5') + }) +}) + +describe('getProductById', () => { + it('calls GET /api/v1/products/:id and returns detail', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1`, () => HttpResponse.json(mockDetail)), + ) + const result = await getProductById(1) + expect(result).toEqual(mockDetail) + }) +}) + +describe('createProduct', () => { + it('calls POST /api/v1/admin/products with payload', async () => { + let capturedBody: unknown = null + server.use( + http.post(`${API_URL}/api/v1/admin/products`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockDetail, { status: 201 }) + }), + ) + const req = { + nombre: 'Clasificado Estándar', + medioId: 1, + productTypeId: 2, + basePrice: 100.50, + } + await createProduct(req) + expect(capturedBody).toEqual(req) + }) +}) + +describe('updateProduct', () => { + it('calls PUT /api/v1/admin/products/:id with payload', async () => { + let capturedBody: unknown = null + server.use( + http.put(`${API_URL}/api/v1/admin/products/1`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockDetail) + }), + ) + const req = { nombre: 'Modificado', basePrice: 200 } + await updateProduct(1, req) + expect(capturedBody).toEqual(req) + }) +}) + +describe('deactivateProduct', () => { + it('calls DELETE /api/v1/admin/products/:id', async () => { + let called = false + server.use( + http.delete(`${API_URL}/api/v1/admin/products/1`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + await deactivateProduct(1) + expect(called).toBe(true) + }) +}) diff --git a/src/web/src/tests/features/products/hooks.test.ts b/src/web/src/tests/features/products/hooks.test.ts new file mode 100644 index 0000000..8555c1a --- /dev/null +++ b/src/web/src/tests/features/products/hooks.test.ts @@ -0,0 +1,143 @@ +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 { useProducts } from '../../../features/products/hooks/useProducts' +import { useCreateProduct } from '../../../features/products/hooks/useCreateProduct' +import { useUpdateProduct } from '../../../features/products/hooks/useUpdateProduct' +import { useDeactivateProduct } from '../../../features/products/hooks/useDeactivateProduct' +import type { ProductListItem, PagedResult } from '../../../features/products/types' + +const API_URL = 'http://localhost:5000' + +const mockItem: ProductListItem = { + id: 1, + nombre: 'Clasificado', + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 50, + priceDurationDays: null, + isActive: true, +} + +const mockPaged: PagedResult = { + items: [mockItem], + page: 1, + pageSize: 20, + total: 1, +} + +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 ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) +} + +describe('useProducts', () => { + it('returns paged data on success', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)), + ) + const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(mockPaged) + }) + + it('returns error state on failure', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => + HttpResponse.json({ error: 'server_error' }, { status: 500 }), + ), + ) + const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +}) + +describe('useCreateProduct', () => { + it('calls create and invalidates products queries on success', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/products`, () => + HttpResponse.json(mockItem, { status: 201 }), + ), + ) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) + + const { result } = renderHook(() => useCreateProduct(), { wrapper }) + await act(async () => { + result.current.mutate({ + nombre: 'Nuevo Producto', + medioId: 1, + productTypeId: 2, + basePrice: 100, + }) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] }) + }) +}) + +describe('useUpdateProduct', () => { + it('calls update and invalidates products queries on success', async () => { + server.use( + http.put(`${API_URL}/api/v1/admin/products/1`, () => + HttpResponse.json(mockItem), + ), + ) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) + + const { result } = renderHook(() => useUpdateProduct(), { wrapper }) + await act(async () => { + result.current.mutate({ id: 1, data: { nombre: 'Modificado', basePrice: 200 } }) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] }) + }) +}) + +describe('useDeactivateProduct', () => { + it('calls deactivate and invalidates products queries on success', async () => { + server.use( + http.delete(`${API_URL}/api/v1/admin/products/1`, () => + new HttpResponse(null, { status: 204 }), + ), + ) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) + + const { result } = renderHook(() => useDeactivateProduct(), { wrapper }) + await act(async () => { + result.current.mutate(1) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] }) + }) +})