test(frontend): ProductFormDialog + DeactivateProductDialog tests (PRD-002 W3 W4)
This commit is contained in:
@@ -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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeactivateProductDialog', () => {
|
||||||
|
it('renders confirmation message with product name', () => {
|
||||||
|
wrap(
|
||||||
|
<DeactivateProductDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
product={sampleProduct}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<DeactivateProductDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
product={sampleProduct}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<DeactivateProductDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
product={sampleProduct}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<DeactivateProductDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
product={sampleProduct}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||||
|
await waitFor(() => expect(onOpenChange).toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
})
|
||||||
194
src/web/src/tests/features/products/ProductFormDialog.test.tsx
Normal file
194
src/web/src/tests/features/products/ProductFormDialog.test.tsx
Normal file
@@ -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<ProductTypeListItem> = {
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders DialogDescription (accessibility)', () => {
|
||||||
|
withProductTypesHandler()
|
||||||
|
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||||
|
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(<ProductFormDialog open={true} onOpenChange={onOpenChange} />)
|
||||||
|
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(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||||
|
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(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||||
|
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(
|
||||||
|
<ProductFormDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
product={mockProduct}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<ProductFormDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
product={mockProduct}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user