From 4720f6772f2c07d32de5f6b736bbc1098a3a158c Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:36:53 -0300 Subject: [PATCH] test(web): component tests puntos-de-venta --- .../features/puntos-de-venta/Banners.test.tsx | 30 ++++ .../DeactivatePuntoDeVentaModal.test.tsx | 97 +++++++++++ .../puntos-de-venta/PuntoDeVentaForm.test.tsx | 145 ++++++++++++++++ .../PuntosDeVentaListPage.test.tsx | 164 ++++++++++++++++++ 4 files changed, 436 insertions(+) create mode 100644 src/web/src/tests/features/puntos-de-venta/Banners.test.tsx create mode 100644 src/web/src/tests/features/puntos-de-venta/DeactivatePuntoDeVentaModal.test.tsx create mode 100644 src/web/src/tests/features/puntos-de-venta/PuntoDeVentaForm.test.tsx create mode 100644 src/web/src/tests/features/puntos-de-venta/PuntosDeVentaListPage.test.tsx diff --git a/src/web/src/tests/features/puntos-de-venta/Banners.test.tsx b/src/web/src/tests/features/puntos-de-venta/Banners.test.tsx new file mode 100644 index 0000000..c71b365 --- /dev/null +++ b/src/web/src/tests/features/puntos-de-venta/Banners.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MedioInactivoBanner } from '../../../features/puntos-de-venta/components/MedioInactivoBanner' +import { PdvInactivoBanner } from '../../../features/puntos-de-venta/components/PdvInactivoBanner' + +describe('MedioInactivoBanner', () => { + it('renders with medio nombre', () => { + render() + expect(screen.getByText(/medio desactivado/i)).toBeInTheDocument() + expect(screen.getByText(/diario el día/i)).toBeInTheDocument() + }) + + it('renders blocked operations message', () => { + render() + expect(screen.getByText(/puntos de venta/i)).toBeInTheDocument() + }) +}) + +describe('PdvInactivoBanner', () => { + it('renders with pdv nombre', () => { + render() + expect(screen.getByText(/punto de venta desactivado/i)).toBeInTheDocument() + expect(screen.getByText(/pdv central/i)).toBeInTheDocument() + }) + + it('renders reactivate hint', () => { + render() + expect(screen.getByText(/reactivalo/i)).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/puntos-de-venta/DeactivatePuntoDeVentaModal.test.tsx b/src/web/src/tests/features/puntos-de-venta/DeactivatePuntoDeVentaModal.test.tsx new file mode 100644 index 0000000..4dfd43c --- /dev/null +++ b/src/web/src/tests/features/puntos-de-venta/DeactivatePuntoDeVentaModal.test.tsx @@ -0,0 +1,97 @@ +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 } from 'react-router-dom' +import { DeactivatePuntoDeVentaModal } from '../../../features/puntos-de-venta/components/DeactivatePuntoDeVentaModal' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderModal(activo = true) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('DeactivatePuntoDeVentaModal', () => { + it('shows "Desactivar" trigger when pdv is active', () => { + renderModal(true) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument() + }) + + it('shows "Reactivar" trigger when pdv is inactive', () => { + renderModal(false) + expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument() + }) + + it('opens dialog and shows confirmation text', async () => { + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByText(/desactivar punto de venta/i)).toBeInTheDocument(), + ) + expect(screen.getByText(/pdv central/i)).toBeInTheDocument() + }) + + it('calls deactivate endpoint on confirm', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/puntos-de-venta/1/deactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /desactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) + + it('is disabled when disabled prop is true', () => { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + render( + + + + + , + ) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeDisabled() + }) +}) diff --git a/src/web/src/tests/features/puntos-de-venta/PuntoDeVentaForm.test.tsx b/src/web/src/tests/features/puntos-de-venta/PuntoDeVentaForm.test.tsx new file mode 100644 index 0000000..c2407b7 --- /dev/null +++ b/src/web/src/tests/features/puntos-de-venta/PuntoDeVentaForm.test.tsx @@ -0,0 +1,145 @@ +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 } from 'react-router-dom' +import { PuntoDeVentaForm } from '../../../features/puntos-de-venta/components/PuntoDeVentaForm' +import type { PuntoDeVentaFormValues } from '../../../features/puntos-de-venta/components/PuntoDeVentaForm' +import type { PuntoDeVentaDetail } from '../../../features/puntos-de-venta/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true }, + { id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true }, +] + +const samplePdv: PuntoDeVentaDetail = { + id: 10, + medioId: 1, + numeroAFIP: 1, + nombre: 'PdV Central', + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderForm( + opts: { initialData?: PuntoDeVentaDetail; onSubmit?: (v: PuntoDeVentaFormValues) => void } = {}, +) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onSubmit = opts.onSubmit ?? vi.fn() + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 2 }), + ), + ) + render( + + + + + , + ) + return { onSubmit } +} + +describe('PuntoDeVentaForm — create mode', () => { + it('shows validation error when nombre is empty', async () => { + renderForm() + await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i })) + await waitFor(() => + expect(screen.getByText(/nombre es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when numeroAFIP is 0 or negative', async () => { + renderForm() + const numeroInput = screen.getByLabelText(/número afip/i) + await userEvent.clear(numeroInput) + await userEvent.type(numeroInput, '0') + await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i })) + await waitFor(() => + expect(screen.getByText(/mayor a 0/i)).toBeInTheDocument(), + ) + }) + + it('calls onSubmit with correct values on valid form', async () => { + const onSubmit = vi.fn() + renderForm({ onSubmit }) + + // Select medio + const medioTrigger = screen.getByRole('combobox', { name: /medio/i }) + await userEvent.click(medioTrigger) + await waitFor(() => + expect(screen.getByRole('option', { name: 'Diario El Día' })).toBeInTheDocument(), + ) + await userEvent.click(screen.getByRole('option', { name: 'Diario El Día' })) + + // Fill numeroAFIP + const numeroInput = screen.getByLabelText(/número afip/i) + await userEvent.clear(numeroInput) + await userEvent.type(numeroInput, '3') + + // Fill nombre + await userEvent.type(screen.getByLabelText(/nombre/i), 'PdV Norte') + + await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + const firstArg = onSubmit.mock.calls[0][0] + expect(firstArg).toMatchObject({ + medioId: 1, + numeroAFIP: 3, + nombre: 'PdV Norte', + }) + }) + }) +}) + +describe('PuntoDeVentaForm — edit mode', () => { + it('medioId and numeroAFIP are disabled in edit mode', async () => { + renderForm({ initialData: samplePdv }) + const medioTrigger = screen.getByRole('combobox', { name: /medio/i }) + expect(medioTrigger).toBeDisabled() + + const numeroInput = screen.getByLabelText(/número afip/i) as HTMLInputElement + expect(numeroInput.disabled).toBe(true) + }) + + it('pre-fills form with initialData values', async () => { + renderForm({ initialData: samplePdv }) + await waitFor(() => + expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('PdV Central'), + ) + expect((screen.getByLabelText(/número afip/i) as HTMLInputElement).value).toBe('1') + }) + + it('shows "Guardar cambios" button in edit mode', () => { + renderForm({ initialData: samplePdv }) + expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/puntos-de-venta/PuntosDeVentaListPage.test.tsx b/src/web/src/tests/features/puntos-de-venta/PuntosDeVentaListPage.test.tsx new file mode 100644 index 0000000..659351e --- /dev/null +++ b/src/web/src/tests/features/puntos-de-venta/PuntosDeVentaListPage.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +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 { PuntosDeVentaListPage } from '../../../features/puntos-de-venta/pages/PuntosDeVentaListPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const adminWithPdv = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:puntos_de_venta:gestionar'], + mustChangePassword: false, +} + +const userWithoutPdv = { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: [], + mustChangePassword: false, +} + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true }, +] + +function makePdvs(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: i + 1, + medioId: 1, + numeroAFIP: i + 1, + nombre: `PdV ${i + 1}`, + activo: true, + })) +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(user = adminWithPdv) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +describe('PuntosDeVentaListPage', () => { + it('renders rows when API returns items', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () => + HttpResponse.json({ items: makePdvs(3), page: 1, pageSize: 20, total: 3 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('PdV 1')).toBeInTheDocument()) + expect(screen.getByText('PdV 2')).toBeInTheDocument() + expect(screen.getByText('PdV 3')).toBeInTheDocument() + }) + + it('shows empty state when items is empty', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(), + ) + }) + + it('hides "Nuevo punto de venta" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage(userWithoutPdv) + + await waitFor(() => + expect(screen.queryByRole('button', { name: /nuevo punto de venta/i })).not.toBeInTheDocument(), + ) + }) + + it('shows "Nuevo punto de venta" button when user has permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByRole('button', { name: /nuevo punto de venta/i })).toBeInTheDocument(), + ) + }) + + it('prev button disabled on first page', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () => + HttpResponse.json({ items: makePdvs(3), page: 1, pageSize: 20, total: 3 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('PdV 1')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled() + }) +})