ADM-008: Puntos de Venta (CRUD fundacional) #19

Merged
dmolinari merged 18 commits from feature/ADM-008 into main 2026-04-17 17:31:21 +00:00
4 changed files with 436 additions and 0 deletions
Showing only changes of commit 4720f6772f - Show all commits

View File

@@ -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(<MedioInactivoBanner medioNombre="Diario El Día" />)
expect(screen.getByText(/medio desactivado/i)).toBeInTheDocument()
expect(screen.getByText(/diario el día/i)).toBeInTheDocument()
})
it('renders blocked operations message', () => {
render(<MedioInactivoBanner medioNombre="Radio AM" />)
expect(screen.getByText(/puntos de venta/i)).toBeInTheDocument()
})
})
describe('PdvInactivoBanner', () => {
it('renders with pdv nombre', () => {
render(<PdvInactivoBanner puntoDeVentaNombre="PdV Central" />)
expect(screen.getByText(/punto de venta desactivado/i)).toBeInTheDocument()
expect(screen.getByText(/pdv central/i)).toBeInTheDocument()
})
it('renders reactivate hint', () => {
render(<PdvInactivoBanner puntoDeVentaNombre="PdV Sur" />)
expect(screen.getByText(/reactivalo/i)).toBeInTheDocument()
})
})

View File

@@ -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(
<QueryClientProvider client={qc}>
<MemoryRouter>
<DeactivatePuntoDeVentaModal
puntoDeVentaId={1}
puntoDeVentaNombre="PdV Central"
activo={activo}
/>
</MemoryRouter>
</QueryClientProvider>,
)
}
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(
<QueryClientProvider client={qc}>
<MemoryRouter>
<DeactivatePuntoDeVentaModal
puntoDeVentaId={1}
puntoDeVentaNombre="PdV Central"
activo={true}
disabled={true}
/>
</MemoryRouter>
</QueryClientProvider>,
)
expect(screen.getByRole('button', { name: /desactivar/i })).toBeDisabled()
})
})

View File

@@ -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(
<QueryClientProvider client={qc}>
<MemoryRouter>
<PuntoDeVentaForm
initialData={opts.initialData}
isPending={false}
error={null}
onSubmit={onSubmit}
/>
</MemoryRouter>
</QueryClientProvider>,
)
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()
})
})

View File

@@ -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<typeof import('react-router-dom')>()
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(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/admin/puntos-de-venta']}>
<Routes>
<Route path="/admin/puntos-de-venta" element={<PuntosDeVentaListPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
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()
})
})