feat: PRD-001 ProductType (flags + multimedia) #38

Merged
dmolinari merged 10 commits from feature/PRD-001 into main 2026-04-19 15:18:53 +00:00
6 changed files with 972 additions and 0 deletions
Showing only changes of commit 9cb1e84ec0 - Show all commits

View File

@@ -0,0 +1,87 @@
import { useState } from 'react'
import { AlertCircle } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import type { ProductTypeListItem } from '../types'
// ─── Error resolver ────────────────────────────────────────────────────────────
function resolveDeactivateError(err: unknown): string | null {
if (!err) return null
const errObj = err as { response?: { status?: number; data?: { message?: string } } }
if (errObj?.response?.data?.message) {
return errObj.response.data.message
}
return 'Error al desactivar el tipo de producto'
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface DeactivateProductTypeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
productType: ProductTypeListItem
onConfirm: (id: number) => Promise<void> | void
}
// ─── Component ────────────────────────────────────────────────────────────────
export function DeactivateProductTypeDialog({
open,
onOpenChange,
productType,
onConfirm,
}: DeactivateProductTypeDialogProps) {
const [error, setError] = useState<string | null>(null)
const [isPending, setIsPending] = useState(false)
async function handleConfirm() {
setError(null)
setIsPending(true)
try {
await onConfirm(productType.id)
onOpenChange(false)
} catch (err) {
setError(resolveDeactivateError(err))
} finally {
setIsPending(false)
}
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Desactivar tipo de producto</AlertDialogTitle>
<AlertDialogDescription>
¿Desactivar el tipo &ldquo;{productType.nombre}&rdquo;? Los productos asociados conservan
la referencia pero el tipo no aparecerá en listados activos.
</AlertDialogDescription>
</AlertDialogHeader>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
{isPending ? 'Procesando...' : 'Desactivar'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,408 @@
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
// ─── Schema ───────────────────────────────────────────────────────────────────
function nullablePositiveInt() {
return z
.string()
.optional()
.transform((v) => (v === '' || v == null ? null : Number(v)))
.pipe(z.number().int().positive().nullable())
}
function nullablePositiveDecimal() {
return z
.string()
.optional()
.transform((v) => (v === '' || v == null ? null : Number(v)))
.pipe(z.number().positive().nullable())
}
const productTypeFormSchema = z.object({
nombre: z.string().trim().min(1, 'Nombre requerido').max(100, 'Máximo 100 caracteres'),
hasDuration: z.boolean(),
requiresText: z.boolean(),
requiresCategory: z.boolean(),
isBundle: z.boolean(),
allowImages: z.boolean(),
maxImages: nullablePositiveInt(),
maxImageSizeMB: nullablePositiveDecimal(),
maxImageWidth: nullablePositiveInt(),
maxImageHeight: nullablePositiveInt(),
})
// Raw form field types (strings before zod transforms)
type ProductTypeFormRaw = {
nombre: string
hasDuration: boolean
requiresText: boolean
requiresCategory: boolean
isBundle: boolean
allowImages: boolean
maxImages: string
maxImageSizeMB: string
maxImageWidth: string
maxImageHeight: string
}
// Output type after zod transforms (what onSubmit receives at runtime)
export type ProductTypeFormOutput = {
nombre: string
hasDuration: boolean
requiresText: boolean
requiresCategory: boolean
isBundle: boolean
allowImages: boolean
maxImages: number | null
maxImageSizeMB: number | null
maxImageWidth: number | null
maxImageHeight: number | null
}
// ─── Props ────────────────────────────────────────────────────────────────────
export interface ProductTypeFormDefaultValues {
nombre?: string
hasDuration?: boolean
requiresText?: boolean
requiresCategory?: boolean
isBundle?: boolean
allowImages?: boolean
maxImages?: number | null
maxImageSizeMB?: number | null
maxImageWidth?: number | null
maxImageHeight?: number | null
}
interface ProductTypeFormProps {
defaultValues?: ProductTypeFormDefaultValues
onSubmit: (values: ProductTypeFormOutput) => void
onCancel: () => void
isPending?: boolean
isEdit?: boolean
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ProductTypeForm({
defaultValues,
onSubmit,
onCancel,
isPending = false,
isEdit = false,
}: ProductTypeFormProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const form = useForm<ProductTypeFormRaw>({
resolver: zodResolver(productTypeFormSchema) as any,
defaultValues: {
nombre: defaultValues?.nombre ?? '',
hasDuration: defaultValues?.hasDuration ?? false,
requiresText: defaultValues?.requiresText ?? false,
requiresCategory: defaultValues?.requiresCategory ?? false,
isBundle: defaultValues?.isBundle ?? false,
allowImages: defaultValues?.allowImages ?? false,
maxImages: defaultValues?.maxImages != null ? String(defaultValues.maxImages) : '',
maxImageSizeMB: defaultValues?.maxImageSizeMB != null ? String(defaultValues.maxImageSizeMB) : '',
maxImageWidth: defaultValues?.maxImageWidth != null ? String(defaultValues.maxImageWidth) : '',
maxImageHeight: defaultValues?.maxImageHeight != null ? String(defaultValues.maxImageHeight) : '',
},
})
const allowImages = form.watch('allowImages')
useEffect(() => {
form.reset({
nombre: defaultValues?.nombre ?? '',
hasDuration: defaultValues?.hasDuration ?? false,
requiresText: defaultValues?.requiresText ?? false,
requiresCategory: defaultValues?.requiresCategory ?? false,
isBundle: defaultValues?.isBundle ?? false,
allowImages: defaultValues?.allowImages ?? false,
maxImages: defaultValues?.maxImages != null ? String(defaultValues.maxImages) : '',
maxImageSizeMB: defaultValues?.maxImageSizeMB != null ? String(defaultValues.maxImageSizeMB) : '',
maxImageWidth: defaultValues?.maxImageWidth != null ? String(defaultValues.maxImageWidth) : '',
maxImageHeight: defaultValues?.maxImageHeight != null ? String(defaultValues.maxImageHeight) : '',
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValues?.nombre, defaultValues?.allowImages])
function handleSubmit(data: ProductTypeFormOutput) {
// Normalize multimedia to null when allowImages=false
if (!data.allowImages) {
data = {
...data,
maxImages: null,
maxImageSizeMB: null,
maxImageWidth: null,
maxImageHeight: null,
}
}
onSubmit(data)
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(
handleSubmit as unknown as Parameters<typeof form.handleSubmit>[0],
)}
className="space-y-4"
noValidate
>
{/* Nombre */}
<FormField
control={form.control}
name="nombre"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre</FormLabel>
<FormControl>
<Input
{...field}
type="text"
disabled={isPending}
placeholder="Nombre del tipo de producto"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Flags */}
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="hasDuration"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<input
id="hasDuration"
type="checkbox"
checked={field.value as boolean}
onChange={(e) => field.onChange(e.target.checked)}
disabled={isPending}
aria-label="Tiene duración"
className="h-4 w-4 cursor-pointer"
/>
</FormControl>
<FormLabel htmlFor="hasDuration" className="font-normal cursor-pointer">
Tiene duración
</FormLabel>
</FormItem>
)}
/>
<FormField
control={form.control}
name="requiresText"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<input
id="requiresText"
type="checkbox"
checked={field.value as boolean}
onChange={(e) => field.onChange(e.target.checked)}
disabled={isPending}
aria-label="Requiere texto"
className="h-4 w-4 cursor-pointer"
/>
</FormControl>
<FormLabel htmlFor="requiresText" className="font-normal cursor-pointer">
Requiere texto
</FormLabel>
</FormItem>
)}
/>
<FormField
control={form.control}
name="requiresCategory"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<input
id="requiresCategory"
type="checkbox"
checked={field.value as boolean}
onChange={(e) => field.onChange(e.target.checked)}
disabled={isPending}
aria-label="Requiere categoría"
className="h-4 w-4 cursor-pointer"
/>
</FormControl>
<FormLabel htmlFor="requiresCategory" className="font-normal cursor-pointer">
Requiere categoría
</FormLabel>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isBundle"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<input
id="isBundle"
type="checkbox"
checked={field.value as boolean}
onChange={(e) => field.onChange(e.target.checked)}
disabled={isPending}
aria-label="Es bundle"
className="h-4 w-4 cursor-pointer"
/>
</FormControl>
<FormLabel htmlFor="isBundle" className="font-normal cursor-pointer">
Es bundle
</FormLabel>
</FormItem>
)}
/>
</div>
{/* Allow Images toggle */}
<FormField
control={form.control}
name="allowImages"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<input
id="allowImages"
type="checkbox"
checked={field.value as boolean}
onChange={(e) => field.onChange(e.target.checked)}
disabled={isPending}
aria-label="Permite imágenes"
className="h-4 w-4 cursor-pointer"
/>
</FormControl>
<FormLabel htmlFor="allowImages" className="font-normal cursor-pointer">
Permite imágenes
</FormLabel>
</FormItem>
)}
/>
{/* Multimedia fields — always rendered, disabled when allowImages=false */}
<div className="grid grid-cols-2 gap-3 border rounded-md p-3">
<FormField
control={form.control}
name="maxImages"
render={({ field }) => (
<FormItem>
<FormLabel>Máx. imágenes</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending || !allowImages}
placeholder="Sin límite"
aria-label="Máx. imágenes"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxImageSizeMB"
render={({ field }) => (
<FormItem>
<FormLabel>Máx. tamaño (MB)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0.1}
step={0.1}
disabled={isPending || !allowImages}
placeholder="Sin límite"
aria-label="Máx. tamaño (MB)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxImageWidth"
render={({ field }) => (
<FormItem>
<FormLabel>Ancho máx. (px)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending || !allowImages}
placeholder="Sin límite"
aria-label="Ancho máx. (px)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxImageHeight"
render={({ field }) => (
<FormItem>
<FormLabel>Alto máx. (px)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending || !allowImages}
placeholder="Sin límite"
aria-label="Alto máx. (px)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Guardar'}
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,111 @@
import { useState } from 'react'
import { isAxiosError } from 'axios'
import { AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { ProductTypeForm } from './ProductTypeForm'
import type { ProductTypeFormOutput } from './ProductTypeForm'
import { useCreateProductType } from '../hooks/useCreateProductType'
import { useUpdateProductType } from '../hooks/useUpdateProductType'
import type { ProductTypeDetail } from '../types'
// ─── Error resolver ────────────────────────────────────────────────────────────
function resolveBackendError(err: unknown): string | null {
if (!err) return null
if (isAxiosError(err) && err.response?.data) {
const data = err.response.data as { error?: string; message?: string }
return data.message ?? data.error ?? 'Error al guardar el tipo de producto'
}
const errObj = err as { response?: { data?: { message?: string } } }
if (errObj?.response?.data?.message) {
return errObj.response.data.message
}
return 'Error al guardar el tipo de producto'
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface ProductTypeFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
productType?: ProductTypeDetail
onSuccess?: () => void
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ProductTypeFormDialog({
open,
onOpenChange,
productType,
onSuccess,
}: ProductTypeFormDialogProps) {
const [backendError, setBackendError] = useState<string | null>(null)
const isEdit = !!productType
const { mutateAsync: createProductType, isPending: creating } = useCreateProductType()
const { mutateAsync: updateProductType, isPending: updating } = useUpdateProductType()
const isPending = creating || updating
async function handleSubmit(values: ProductTypeFormOutput) {
setBackendError(null)
try {
if (isEdit) {
await updateProductType({ id: productType.id, data: values })
toast.success('Tipo de producto actualizado')
} else {
await createProductType(values)
toast.success('Tipo de producto creado')
}
onOpenChange(false)
onSuccess?.()
} catch (err) {
const msg = resolveBackendError(err)
setBackendError(msg)
if (
!isAxiosError(err) ||
(err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400)
) {
toast.error(isEdit ? 'Error al actualizar tipo de producto' : 'Error al crear tipo de producto')
}
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Editar tipo de producto' : 'Nuevo tipo de producto'}</DialogTitle>
<DialogDescription>
{isEdit
? `Modificá los datos del tipo "${productType?.nombre ?? ''}".`
: 'Completá los datos para crear un nuevo tipo de producto.'}
</DialogDescription>
</DialogHeader>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
<ProductTypeForm
defaultValues={productType}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
isPending={isPending}
isEdit={isEdit}
/>
</DialogContent>
</Dialog>
)
}

View File

@@ -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 { DeactivateProductTypeDialog } from '../../../features/product-types/components/DeactivateProductTypeDialog'
import type { ProductTypeListItem } from '../../../features/product-types/types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const sampleProductType: ProductTypeListItem = {
id: 1,
nombre: 'Clasificados',
hasDuration: true,
requiresText: false,
requiresCategory: false,
isBundle: false,
allowImages: false,
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('DeactivateProductTypeDialog', () => {
it('renders confirmation message with product type name', () => {
wrap(
<DeactivateProductTypeDialog
open={true}
onOpenChange={vi.fn()}
productType={sampleProductType}
onConfirm={vi.fn()}
/>,
)
expect(screen.getByText(/Clasificados/i)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /desactivar tipo/i })).toBeInTheDocument()
})
it('calls onConfirm with product type id when user confirms', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
wrap(
<DeactivateProductTypeDialog
open={true}
onOpenChange={vi.fn()}
productType={sampleProductType}
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(sampleProductType.id))
})
it('shows inline error when backend returns 409 EnUso', async () => {
const onConfirm = vi.fn(() =>
Promise.reject({
response: { status: 409, data: { message: 'No se puede desactivar: el tipo está en uso por Products.' } },
}),
)
wrap(
<DeactivateProductTypeDialog
open={true}
onOpenChange={vi.fn()}
productType={sampleProductType}
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(
<DeactivateProductTypeDialog
open={true}
onOpenChange={onOpenChange}
productType={sampleProductType}
onConfirm={vi.fn()}
/>,
)
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
await waitFor(() => expect(onOpenChange).toHaveBeenCalled())
})
})

View File

@@ -0,0 +1,129 @@
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 { ProductTypeForm } from '../../../features/product-types/components/ProductTypeForm'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
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>,
)
}
// ─── ProductTypeForm — field rendering ─────────────────────────────────────
describe('ProductTypeForm — field rendering', () => {
it('renders nombre field and all flag checkboxes', () => {
wrap(
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} />,
)
expect(screen.getByLabelText(/nombre/i)).toBeInTheDocument()
expect(screen.getByLabelText('Tiene duración')).toBeInTheDocument()
expect(screen.getByLabelText('Requiere texto')).toBeInTheDocument()
expect(screen.getByLabelText('Requiere categoría')).toBeInTheDocument()
expect(screen.getByLabelText('Es bundle')).toBeInTheDocument()
expect(screen.getByLabelText('Permite imágenes')).toBeInTheDocument()
})
it('renders multimedia fields when allowImages is true', async () => {
wrap(
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: true }} />,
)
expect(screen.getByLabelText('Máx. imágenes')).toBeInTheDocument()
expect(screen.getByLabelText('Máx. tamaño (MB)')).toBeInTheDocument()
expect(screen.getByLabelText('Ancho máx. (px)')).toBeInTheDocument()
expect(screen.getByLabelText('Alto máx. (px)')).toBeInTheDocument()
})
it('disables multimedia fields when allowImages is false', () => {
wrap(
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
)
expect(screen.getByLabelText('Máx. imágenes')).toBeDisabled()
expect(screen.getByLabelText('Máx. tamaño (MB)')).toBeDisabled()
expect(screen.getByLabelText('Ancho máx. (px)')).toBeDisabled()
expect(screen.getByLabelText('Alto máx. (px)')).toBeDisabled()
})
})
// ─── ProductTypeForm — allowImages toggle ──────────────────────────────────
describe('ProductTypeForm — allowImages toggle', () => {
it('enables multimedia fields after toggling allowImages on', async () => {
wrap(
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
)
const allowImagesCheckbox = screen.getByLabelText('Permite imágenes')
await userEvent.click(allowImagesCheckbox)
await waitFor(() => {
expect(screen.getByLabelText('Máx. imágenes')).not.toBeDisabled()
})
})
})
// ─── ProductTypeForm — validation ─────────────────────────────────────────
describe('ProductTypeForm — zod validation', () => {
it('shows validation error when nombre is empty', async () => {
wrap(
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} />,
)
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
await waitFor(() => {
expect(screen.getByText(/nombre.*requerido/i)).toBeInTheDocument()
})
})
it('calls onSubmit with correct payload when form is valid', async () => {
const onSubmit = vi.fn()
wrap(
<ProductTypeForm onSubmit={onSubmit} onCancel={vi.fn()} />,
)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Clasificados')
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
const payload = onSubmit.mock.calls[0][0]
expect(payload).toMatchObject({ nombre: 'Clasificados' })
})
})
it('normalizes multimedia fields to null when allowImages is false on submit', async () => {
const onSubmit = vi.fn()
wrap(
<ProductTypeForm onSubmit={onSubmit} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Sin imágenes')
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
const payload = onSubmit.mock.calls[0][0]
expect(payload.maxImages).toBeNull()
expect(payload.maxImageSizeMB).toBeNull()
expect(payload.maxImageWidth).toBeNull()
expect(payload.maxImageHeight).toBeNull()
})
})
})
// ─── ProductTypeForm — cancel ──────────────────────────────────────────────
describe('ProductTypeForm — cancel', () => {
it('calls onCancel when cancel button is clicked', async () => {
const onCancel = vi.fn()
wrap(
<ProductTypeForm onSubmit={vi.fn()} onCancel={onCancel} />,
)
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
expect(onCancel).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,138 @@
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 { ProductTypeFormDialog } from '../../../features/product-types/components/ProductTypeFormDialog'
import type { ProductTypeDetail } from '../../../features/product-types/types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const API_URL = 'http://localhost:5000'
const mockDetail: ProductTypeDetail = {
id: 1,
nombre: 'Clasificados',
hasDuration: true,
requiresText: true,
requiresCategory: false,
isBundle: false,
allowImages: false,
maxImages: null,
maxImageSizeMB: null,
maxImageWidth: null,
maxImageHeight: 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>,
)
}
// ─── ProductTypeFormDialog — create mode ──────────────────────────────────
describe('ProductTypeFormDialog — create mode', () => {
it('renders create dialog title when no productType prop', () => {
wrap(
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
)
expect(screen.getByRole('heading', { name: /nuevo tipo/i })).toBeInTheDocument()
})
it('has aria-describedby on DialogDescription (NFR8)', () => {
wrap(
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
)
const desc = screen.getByText(/completá los datos/i)
expect(desc).toBeInTheDocument()
})
it('calls create mutation and closes dialog on success', async () => {
server.use(
http.post(`${API_URL}/api/v1/admin/product-types`, () =>
HttpResponse.json({ id: 5, nombre: 'Nuevo Tipo' }, { status: 201 }),
),
)
const onOpenChange = vi.fn()
wrap(
<ProductTypeFormDialog open={true} onOpenChange={onOpenChange} />,
)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Nuevo Tipo')
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
it('shows inline error when backend returns 409 NombreDuplicado', async () => {
server.use(
http.post(`${API_URL}/api/v1/admin/product-types`, () =>
HttpResponse.json(
{ error: 'producto_tipo_nombre_duplicado', message: 'Ya existe un tipo con ese nombre' },
{ status: 409 },
),
),
)
wrap(
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado')
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
await waitFor(() => {
expect(screen.getByText(/ya existe un tipo con ese nombre/i)).toBeInTheDocument()
})
})
})
// ─── ProductTypeFormDialog — edit mode ────────────────────────────────────
describe('ProductTypeFormDialog — edit mode', () => {
it('renders edit dialog title and pre-fills nombre', () => {
wrap(
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} productType={mockDetail} />,
)
expect(screen.getByRole('heading', { name: /editar tipo/i })).toBeInTheDocument()
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
expect(input.value).toBe('Clasificados')
})
it('calls update mutation and closes dialog on success', async () => {
server.use(
http.put(`${API_URL}/api/v1/admin/product-types/1`, () =>
HttpResponse.json({ ...mockDetail, nombre: 'Modificado' }),
),
)
const onOpenChange = vi.fn()
wrap(
<ProductTypeFormDialog open={true} onOpenChange={onOpenChange} productType={mockDetail} />,
)
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)
})
})
})