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()
+ })
+})