From 0f5455aba6fcaacd620a273cdf842bcda224c10e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:35:23 -0300 Subject: [PATCH] test(frontend): ProductFormDialog + DeactivateProductDialog tests (PRD-002 W3 W4) --- .../products/DeactivateProductDialog.test.tsx | 99 +++++++++ .../products/ProductFormDialog.test.tsx | 194 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 src/web/src/tests/features/products/DeactivateProductDialog.test.tsx create mode 100644 src/web/src/tests/features/products/ProductFormDialog.test.tsx diff --git a/src/web/src/tests/features/products/DeactivateProductDialog.test.tsx b/src/web/src/tests/features/products/DeactivateProductDialog.test.tsx new file mode 100644 index 0000000..e9a1810 --- /dev/null +++ b/src/web/src/tests/features/products/DeactivateProductDialog.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import React from 'react' +import { DeactivateProductDialog } from '../../../features/products/components/DeactivateProductDialog' +import type { ProductListItem } from '../../../features/products/types' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const sampleProduct: ProductListItem = { + id: 1, + nombre: 'Clasificado Estándar', + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 100.5, + priceDurationDays: null, + isActive: true, +} + +function wrap(children: React.ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + {children} + , + ) +} + +describe('DeactivateProductDialog', () => { + it('renders confirmation message with product name', () => { + wrap( + , + ) + expect(screen.getByText(/Clasificado Estándar/i)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument() + }) + + it('calls onConfirm with product id when user confirms', async () => { + const onConfirm = vi.fn().mockResolvedValue(undefined) + wrap( + , + ) + const buttons = screen.getAllByRole('button', { name: /desactivar/i }) + const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')! + await userEvent.click(confirmBtn) + await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(sampleProduct.id)) + }) + + it('shows inline error when backend returns 409', async () => { + const onConfirm = vi.fn(() => + Promise.reject({ + response: { status: 409, data: { message: 'No se puede desactivar: el producto está en uso.' } }, + }), + ) + wrap( + , + ) + const buttons = screen.getAllByRole('button', { name: /desactivar/i }) + const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')! + await userEvent.click(confirmBtn) + await waitFor(() => { + expect(screen.getByText(/en uso/i)).toBeInTheDocument() + }) + }) + + it('closes dialog when cancel is clicked', async () => { + const onOpenChange = vi.fn() + wrap( + , + ) + await userEvent.click(screen.getByRole('button', { name: /cancelar/i })) + await waitFor(() => expect(onOpenChange).toHaveBeenCalled()) + }) +}) diff --git a/src/web/src/tests/features/products/ProductFormDialog.test.tsx b/src/web/src/tests/features/products/ProductFormDialog.test.tsx new file mode 100644 index 0000000..2b79712 --- /dev/null +++ b/src/web/src/tests/features/products/ProductFormDialog.test.tsx @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } 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 } from 'react-router-dom' +import React from 'react' +import { ProductFormDialog } from '../../../features/products/components/ProductFormDialog' +import type { ProductDetail } from '../../../features/products/types' +import type { PagedResult } from '../../../features/product-types/types' +import type { ProductTypeListItem } from '../../../features/product-types/types' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const API_URL = 'http://localhost:5000' + +const ptSimple: ProductTypeListItem = { + id: 2, + nombre: 'Simple', + hasDuration: false, + requiresText: false, + requiresCategory: false, + isBundle: false, + allowImages: false, + isActive: true, +} + +const mockProductTypesPaged: PagedResult = { + items: [ptSimple], + page: 1, + pageSize: 50, + total: 1, +} + +const mockProduct: ProductDetail = { + id: 1, + nombre: 'Clasificado Estándar', + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 100.5, + priceDurationDays: null, + isActive: true, + fechaCreacion: '2026-04-19T00:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function wrap(children: React.ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + {children} + , + ) +} + +function withProductTypesHandler() { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => + HttpResponse.json(mockProductTypesPaged), + ), + ) +} + +// ─── ProductFormDialog — create mode ───────────────────────────────────── + +describe('ProductFormDialog — create mode', () => { + it('renders create dialog title when no product prop', () => { + withProductTypesHandler() + wrap() + expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument() + }) + + it('renders DialogDescription (accessibility)', () => { + withProductTypesHandler() + wrap() + expect(screen.getByText(/completá los datos/i)).toBeInTheDocument() + }) + + it('calls create mutation and closes dialog on success', async () => { + withProductTypesHandler() + server.use( + http.post(`${API_URL}/api/v1/admin/products`, () => + HttpResponse.json({ id: 10, nombre: 'Nuevo Producto' }, { status: 201 }), + ), + ) + const onOpenChange = vi.fn() + wrap() + await userEvent.type(screen.getByLabelText(/nombre/i), 'Nuevo Producto') + await userEvent.type(screen.getByLabelText(/id de medio/i), '1') + await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2') + await userEvent.type(screen.getByLabelText(/precio base/i), '100') + await userEvent.click(screen.getByRole('button', { name: /guardar/i })) + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + }) + + it('shows inline error when backend returns 409', async () => { + withProductTypesHandler() + server.use( + http.post(`${API_URL}/api/v1/admin/products`, () => + HttpResponse.json( + { error: 'product_nombre_duplicado', message: 'Ya existe un producto con ese nombre' }, + { status: 409 }, + ), + ), + ) + wrap() + await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado') + await userEvent.type(screen.getByLabelText(/id de medio/i), '1') + await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2') + await userEvent.type(screen.getByLabelText(/precio base/i), '100') + await userEvent.click(screen.getByRole('button', { name: /guardar/i })) + await waitFor(() => { + expect(screen.getByText(/ya existe un producto con ese nombre/i)).toBeInTheDocument() + }) + }) + + it('shows inline error when backend returns 409 ProductTypeInactivo', async () => { + withProductTypesHandler() + server.use( + http.post(`${API_URL}/api/v1/admin/products`, () => + HttpResponse.json( + { error: 'product_type_inactivo', message: 'El tipo de producto está inactivo' }, + { status: 409 }, + ), + ), + ) + wrap() + await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test') + await userEvent.type(screen.getByLabelText(/id de medio/i), '1') + await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2') + await userEvent.type(screen.getByLabelText(/precio base/i), '100') + await userEvent.click(screen.getByRole('button', { name: /guardar/i })) + await waitFor(() => { + expect(screen.getByText(/tipo de producto está inactivo/i)).toBeInTheDocument() + }) + }) +}) + +// ─── ProductFormDialog — edit mode ──────────────────────────────────────── + +describe('ProductFormDialog — edit mode', () => { + it('renders edit dialog title and pre-fills nombre', () => { + withProductTypesHandler() + wrap( + , + ) + expect(screen.getByRole('heading', { name: /editar producto/i })).toBeInTheDocument() + const input = screen.getByLabelText(/nombre/i) as HTMLInputElement + expect(input.value).toBe('Clasificado Estándar') + }) + + it('calls update mutation and closes dialog on success', async () => { + withProductTypesHandler() + server.use( + http.put(`${API_URL}/api/v1/admin/products/1`, () => + HttpResponse.json({ ...mockProduct, nombre: 'Modificado' }), + ), + ) + const onOpenChange = vi.fn() + wrap( + , + ) + const input = screen.getByLabelText(/nombre/i) as HTMLInputElement + await userEvent.clear(input) + await userEvent.type(input, 'Modificado') + await userEvent.click(screen.getByRole('button', { name: /guardar/i })) + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + }) +})