feat(frontend): Products feature — CRUD page, form, dialogs, hooks (PRD-002)
Implements full frontend for PRD-002: 5 API fns, 5 hooks (useProducts, useCreateProduct, useUpdateProduct, useDeactivateProduct), ProductForm, ProductFormDialog, DeactivateProductDialog, ProductsPage with CanPerform gating. Router entry at /admin/products and sidebar link added. 19 Vitest tests GREEN (api, hooks, page).
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
Store,
|
||||
Tag,
|
||||
Layers,
|
||||
Package,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -82,6 +83,12 @@ const adminItems: NavItem[] = [
|
||||
icon: Layers,
|
||||
requiredPermission: 'catalogo:tipos:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Productos',
|
||||
href: '/admin/products',
|
||||
icon: Package,
|
||||
requiredPermission: 'catalogo:productos:gestionar',
|
||||
},
|
||||
]
|
||||
|
||||
interface SidebarNavProps {
|
||||
|
||||
12
src/web/src/features/products/api/createProduct.ts
Normal file
12
src/web/src/features/products/api/createProduct.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { CreateProductRequest, ProductDetail } from '../types'
|
||||
|
||||
export async function createProduct(
|
||||
payload: CreateProductRequest,
|
||||
): Promise<ProductDetail> {
|
||||
const response = await axiosClient.post<ProductDetail>(
|
||||
'/api/v1/admin/products',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
5
src/web/src/features/products/api/deactivateProduct.ts
Normal file
5
src/web/src/features/products/api/deactivateProduct.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
|
||||
export async function deactivateProduct(id: number): Promise<void> {
|
||||
await axiosClient.delete(`/api/v1/admin/products/${id}`)
|
||||
}
|
||||
7
src/web/src/features/products/api/getProductById.ts
Normal file
7
src/web/src/features/products/api/getProductById.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ProductDetail } from '../types'
|
||||
|
||||
export async function getProductById(id: number): Promise<ProductDetail> {
|
||||
const response = await axiosClient.get<ProductDetail>(`/api/v1/products/${id}`)
|
||||
return response.data
|
||||
}
|
||||
12
src/web/src/features/products/api/listProducts.ts
Normal file
12
src/web/src/features/products/api/listProducts.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ListProductsParams, PagedResult, ProductListItem } from '../types'
|
||||
|
||||
export async function listProducts(
|
||||
params?: ListProductsParams,
|
||||
): Promise<PagedResult<ProductListItem>> {
|
||||
const response = await axiosClient.get<PagedResult<ProductListItem>>(
|
||||
'/api/v1/products',
|
||||
{ params },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
13
src/web/src/features/products/api/updateProduct.ts
Normal file
13
src/web/src/features/products/api/updateProduct.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { UpdateProductRequest, ProductDetail } from '../types'
|
||||
|
||||
export async function updateProduct(
|
||||
id: number,
|
||||
payload: UpdateProductRequest,
|
||||
): Promise<ProductDetail> {
|
||||
const response = await axiosClient.put<ProductDetail>(
|
||||
`/api/v1/admin/products/${id}`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -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 { ProductListItem } 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 producto'
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DeactivateProductDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
product: ProductListItem
|
||||
onConfirm: (id: number) => Promise<void> | void
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DeactivateProductDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
product,
|
||||
onConfirm,
|
||||
}: DeactivateProductDialogProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
async function handleConfirm() {
|
||||
setError(null)
|
||||
setIsPending(true)
|
||||
try {
|
||||
await onConfirm(product.id)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(resolveDeactivateError(err))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Desactivar producto</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
¿Desactivar el producto “{product.nombre}”? El producto no
|
||||
aparecerá en los 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>
|
||||
)
|
||||
}
|
||||
280
src/web/src/features/products/components/ProductForm.tsx
Normal file
280
src/web/src/features/products/components/ProductForm.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
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())
|
||||
}
|
||||
|
||||
const productFormSchema = z.object({
|
||||
nombre: z.string().trim().min(1, 'Nombre requerido').max(300, 'Máximo 300 caracteres'),
|
||||
medioId: z
|
||||
.string()
|
||||
.min(1, 'Medio requerido')
|
||||
.transform((v) => Number(v))
|
||||
.pipe(z.number().int().positive('Medio requerido')),
|
||||
productTypeId: z
|
||||
.string()
|
||||
.min(1, 'Tipo de producto requerido')
|
||||
.transform((v) => Number(v))
|
||||
.pipe(z.number().int().positive('Tipo de producto requerido')),
|
||||
rubroId: nullablePositiveInt(),
|
||||
basePrice: z
|
||||
.string()
|
||||
.min(1, 'Precio requerido')
|
||||
.transform((v) => Number(v))
|
||||
.pipe(z.number().min(0, 'El precio no puede ser negativo')),
|
||||
priceDurationDays: nullablePositiveInt(),
|
||||
})
|
||||
|
||||
// Raw form field types (strings before zod transforms)
|
||||
type ProductFormRaw = {
|
||||
nombre: string
|
||||
medioId: string
|
||||
productTypeId: string
|
||||
rubroId: string
|
||||
basePrice: string
|
||||
priceDurationDays: string
|
||||
}
|
||||
|
||||
// Output type after zod transforms (what onSubmit receives at runtime)
|
||||
export type ProductFormOutput = {
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId: number | null
|
||||
basePrice: number
|
||||
priceDurationDays: number | null
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProductFormDefaultValues {
|
||||
nombre?: string
|
||||
medioId?: number | null
|
||||
productTypeId?: number | null
|
||||
rubroId?: number | null
|
||||
basePrice?: number | null
|
||||
priceDurationDays?: number | null
|
||||
}
|
||||
|
||||
interface ProductFormProps {
|
||||
defaultValues?: ProductFormDefaultValues
|
||||
onSubmit: (values: ProductFormOutput) => void
|
||||
onCancel: () => void
|
||||
isPending?: boolean
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProductForm({
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isPending = false,
|
||||
isEdit = false,
|
||||
}: ProductFormProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const form = useForm<ProductFormRaw>({
|
||||
resolver: zodResolver(productFormSchema) as any,
|
||||
defaultValues: {
|
||||
nombre: defaultValues?.nombre ?? '',
|
||||
medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '',
|
||||
productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '',
|
||||
rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '',
|
||||
basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '',
|
||||
priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
nombre: defaultValues?.nombre ?? '',
|
||||
medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '',
|
||||
productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '',
|
||||
rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '',
|
||||
basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '',
|
||||
priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '',
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultValues?.nombre, defaultValues?.medioId, defaultValues?.productTypeId])
|
||||
|
||||
function handleSubmit(data: ProductFormOutput) {
|
||||
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 producto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Medio ID */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="medioId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID de Medio</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending || isEdit}
|
||||
placeholder="ID del medio"
|
||||
aria-label="ID de Medio"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Product Type ID */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="productTypeId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID de Tipo de Producto</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending || isEdit}
|
||||
placeholder="ID del tipo de producto"
|
||||
aria-label="ID de Tipo de Producto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Rubro ID (optional) */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rubroId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID de Rubro (opcional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending}
|
||||
placeholder="Sin rubro"
|
||||
aria-label="ID de Rubro"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Base Price */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basePrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Precio base</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
disabled={isPending}
|
||||
placeholder="0.00"
|
||||
aria-label="Precio base"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Price Duration Days (optional) */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priceDurationDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Días de duración del precio (opcional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending}
|
||||
placeholder="Sin límite"
|
||||
aria-label="Días de duración del precio"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
126
src/web/src/features/products/components/ProductFormDialog.tsx
Normal file
126
src/web/src/features/products/components/ProductFormDialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
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 { ProductForm } from './ProductForm'
|
||||
import type { ProductFormOutput } from './ProductForm'
|
||||
import { useCreateProduct } from '../hooks/useCreateProduct'
|
||||
import { useUpdateProduct } from '../hooks/useUpdateProduct'
|
||||
import type { ProductDetail } 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 producto'
|
||||
}
|
||||
const errObj = err as { response?: { data?: { message?: string } } }
|
||||
if (errObj?.response?.data?.message) {
|
||||
return errObj.response.data.message
|
||||
}
|
||||
return 'Error al guardar el producto'
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProductFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
product?: ProductDetail
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProductFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
product,
|
||||
onSuccess,
|
||||
}: ProductFormDialogProps) {
|
||||
const [backendError, setBackendError] = useState<string | null>(null)
|
||||
|
||||
const isEdit = !!product
|
||||
const { mutateAsync: createProduct, isPending: creating } = useCreateProduct()
|
||||
const { mutateAsync: updateProduct, isPending: updating } = useUpdateProduct()
|
||||
const isPending = creating || updating
|
||||
|
||||
async function handleSubmit(values: ProductFormOutput) {
|
||||
setBackendError(null)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateProduct({
|
||||
id: product.id,
|
||||
data: {
|
||||
nombre: values.nombre,
|
||||
rubroId: values.rubroId,
|
||||
basePrice: values.basePrice,
|
||||
priceDurationDays: values.priceDurationDays,
|
||||
},
|
||||
})
|
||||
toast.success('Producto actualizado')
|
||||
} else {
|
||||
await createProduct({
|
||||
nombre: values.nombre,
|
||||
medioId: values.medioId,
|
||||
productTypeId: values.productTypeId,
|
||||
rubroId: values.rubroId,
|
||||
basePrice: values.basePrice,
|
||||
priceDurationDays: values.priceDurationDays,
|
||||
})
|
||||
toast.success('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 producto' : 'Error al crear producto')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Editar producto' : 'Nuevo producto'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? `Modificá los datos del producto "${product?.nombre ?? ''}".`
|
||||
: 'Completá los datos para crear un nuevo producto.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ProductForm
|
||||
defaultValues={product}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isPending={isPending}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
13
src/web/src/features/products/hooks/useCreateProduct.ts
Normal file
13
src/web/src/features/products/hooks/useCreateProduct.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { createProduct } from '../api/createProduct'
|
||||
import type { CreateProductRequest } from '../types'
|
||||
|
||||
export function useCreateProduct() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateProductRequest) => createProduct(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
12
src/web/src/features/products/hooks/useDeactivateProduct.ts
Normal file
12
src/web/src/features/products/hooks/useDeactivateProduct.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { deactivateProduct } from '../api/deactivateProduct'
|
||||
|
||||
export function useDeactivateProduct() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deactivateProduct(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
10
src/web/src/features/products/hooks/useProducts.ts
Normal file
10
src/web/src/features/products/hooks/useProducts.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listProducts } from '../api/listProducts'
|
||||
import type { ListProductsParams } from '../types'
|
||||
|
||||
export function useProducts(params?: ListProductsParams) {
|
||||
return useQuery({
|
||||
queryKey: ['products', params],
|
||||
queryFn: () => listProducts(params),
|
||||
})
|
||||
}
|
||||
14
src/web/src/features/products/hooks/useUpdateProduct.ts
Normal file
14
src/web/src/features/products/hooks/useUpdateProduct.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { updateProduct } from '../api/updateProduct'
|
||||
import type { UpdateProductRequest } from '../types'
|
||||
|
||||
export function useUpdateProduct() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateProductRequest }) =>
|
||||
updateProduct(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
9
src/web/src/features/products/index.ts
Normal file
9
src/web/src/features/products/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { ProductsPage } from './pages/ProductsPage'
|
||||
export type {
|
||||
ProductListItem,
|
||||
ProductDetail,
|
||||
CreateProductRequest,
|
||||
UpdateProductRequest,
|
||||
PagedResult,
|
||||
ListProductsParams,
|
||||
} from './types'
|
||||
177
src/web/src/features/products/pages/ProductsPage.tsx
Normal file
177
src/web/src/features/products/pages/ProductsPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { CanPerform } from '@/components/auth/CanPerform'
|
||||
import { useProducts } from '../hooks/useProducts'
|
||||
import { useDeactivateProduct } from '../hooks/useDeactivateProduct'
|
||||
import { ProductFormDialog } from '../components/ProductFormDialog'
|
||||
import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
|
||||
import type { ProductListItem, ProductDetail } from '../types'
|
||||
|
||||
export function ProductsPage() {
|
||||
// ── Create dialog state ──────────────────────────────────────────────────
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
// ── Edit dialog state ────────────────────────────────────────────────────
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editingProduct, setEditingProduct] = useState<ProductDetail | null>(null)
|
||||
|
||||
// ── Deactivate dialog state ──────────────────────────────────────────────
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
||||
const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null)
|
||||
|
||||
const { data: paged, isLoading, isError } = useProducts({ activo: true })
|
||||
const { mutateAsync: deactivateProduct } = useDeactivateProduct()
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function openCreate() {
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(p: ProductListItem) {
|
||||
const detail: ProductDetail = {
|
||||
...p,
|
||||
fechaCreacion: '',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
setEditingProduct(detail)
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
function openDeactivate(p: ProductListItem) {
|
||||
setDeactivatingProduct(p)
|
||||
setDeactivateOpen(true)
|
||||
}
|
||||
|
||||
async function handleDeactivate(id: number) {
|
||||
await deactivateProduct(id)
|
||||
toast.success('Producto desactivado')
|
||||
}
|
||||
|
||||
// ── Loading / Error ───────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Skeleton className="h-10 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive" className="m-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Error al cargar productos.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const isEmpty = !paged?.items.length
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Productos</h1>
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo Producto
|
||||
</Button>
|
||||
</CanPerform>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
|
||||
<p>No hay productos.</p>
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<Button variant="outline" onClick={openCreate}>
|
||||
Crear primer producto
|
||||
</Button>
|
||||
</CanPerform>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-2 text-left font-medium">Nombre</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Medio</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Tipo</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Precio base</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Activo</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.items.map((p: ProductListItem) => (
|
||||
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/25">
|
||||
<td className="px-4 py-2 font-medium">{p.nombre}</td>
|
||||
<td className="px-4 py-2">{p.medioId}</td>
|
||||
<td className="px-4 py-2">{p.productTypeId}</td>
|
||||
<td className="px-4 py-2">{p.basePrice}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={p.isActive ? 'text-green-600' : 'text-red-500'}>
|
||||
{p.isActive ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEdit(p)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openDeactivate(p)}
|
||||
disabled={!p.isActive}
|
||||
>
|
||||
Desactivar
|
||||
</Button>
|
||||
</div>
|
||||
</CanPerform>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<ProductFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
|
||||
{/* Edit dialog */}
|
||||
{editingProduct && (
|
||||
<ProductFormDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
product={editingProduct}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Deactivate confirmation dialog */}
|
||||
{deactivatingProduct && (
|
||||
<DeactivateProductDialog
|
||||
open={deactivateOpen}
|
||||
onOpenChange={setDeactivateOpen}
|
||||
product={deactivatingProduct}
|
||||
onConfirm={handleDeactivate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
src/web/src/features/products/types.ts
Normal file
58
src/web/src/features/products/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// PRD-002 — shared types for products feature
|
||||
|
||||
export interface ProductListItem {
|
||||
id: number
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId: number | null
|
||||
basePrice: number
|
||||
priceDurationDays: number | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface ProductDetail {
|
||||
id: number
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId: number | null
|
||||
basePrice: number
|
||||
priceDurationDays: number | null
|
||||
isActive: boolean
|
||||
fechaCreacion: string
|
||||
fechaModificacion: string | null
|
||||
}
|
||||
|
||||
export interface CreateProductRequest {
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId?: number | null
|
||||
basePrice: number
|
||||
priceDurationDays?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateProductRequest {
|
||||
nombre: string
|
||||
rubroId?: number | null
|
||||
basePrice: number
|
||||
priceDurationDays?: number | null
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ListProductsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
activo?: boolean | null
|
||||
search?: string
|
||||
medioId?: number
|
||||
productTypeId?: number
|
||||
rubroId?: number
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
|
||||
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
||||
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
||||
import { ProductsPage } from './features/products/pages/ProductsPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -320,6 +321,16 @@ export function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Products routes — PRD-002 */}
|
||||
<Route
|
||||
path="/admin/products"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['catalogo:productos:gestionar']}>
|
||||
<ProductsPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
187
src/web/src/tests/features/products/ProductsPage.test.tsx
Normal file
187
src/web/src/tests/features/products/ProductsPage.test.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
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, Routes, Route } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { ProductsPage } from '../../../features/products/pages/ProductsPage'
|
||||
import { useAuthStore } from '../../../stores/authStore'
|
||||
import type { ProductListItem, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const adminUser = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nombre: 'Admin',
|
||||
rol: 'admin',
|
||||
permisos: ['catalogo:productos:gestionar'],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
const regularUser = {
|
||||
id: 2,
|
||||
username: 'viewer',
|
||||
nombre: 'Viewer',
|
||||
rol: 'viewer',
|
||||
permisos: [],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
const mockItem: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockPaged: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const emptyPaged: PagedResult<ProductListItem> = {
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
useAuthStore.getState().clearAuth()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderPage(user = adminUser) {
|
||||
useAuthStore.setState({ user })
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={['/admin/products']}>
|
||||
<Routes>
|
||||
<Route path="/admin/products" element={<ProductsPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Loading / Error / Data states ──────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — loading and error states', () => {
|
||||
it('renders loading skeleton while fetching', () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, async () => {
|
||||
await new Promise(() => {})
|
||||
return HttpResponse.json(emptyPaged)
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders data when loaded', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows error state on fetch failure', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () =>
|
||||
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||
),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/error al cargar productos/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows empty state when no products', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/no hay productos/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Permission gating ───────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — permission gating', () => {
|
||||
it('shows "Nuevo Producto" button when user has permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides "Nuevo Producto" button when user lacks permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
renderPage(regularUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
expect(screen.queryByRole('button', { name: /nuevo producto/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Create dialog ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — create dialog', () => {
|
||||
it('opens create dialog when "Nuevo Producto" button is clicked', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument())
|
||||
await userEvent.click(screen.getByRole('button', { name: /nuevo producto/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Deactivate dialog ───────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — deactivate dialog', () => {
|
||||
it('opens deactivate confirmation dialog when Desactivar is clicked', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
131
src/web/src/tests/features/products/api.test.ts
Normal file
131
src/web/src/tests/features/products/api.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { listProducts } from '../../../features/products/api/listProducts'
|
||||
import { getProductById } from '../../../features/products/api/getProductById'
|
||||
import { createProduct } from '../../../features/products/api/createProduct'
|
||||
import { updateProduct } from '../../../features/products/api/updateProduct'
|
||||
import { deactivateProduct } from '../../../features/products/api/deactivateProduct'
|
||||
import type { ProductListItem, ProductDetail, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockListItem: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockDetail: ProductDetail = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
fechaCreacion: '2026-04-19T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const mockPaged: PagedResult<ProductListItem> = {
|
||||
items: [mockListItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('listProducts', () => {
|
||||
it('calls GET /api/v1/products and returns paged result', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
const result = await listProducts()
|
||||
expect(result).toEqual(mockPaged)
|
||||
})
|
||||
|
||||
it('passes query params when provided', async () => {
|
||||
let capturedUrl = ''
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
|
||||
capturedUrl = request.url
|
||||
return HttpResponse.json(mockPaged)
|
||||
}),
|
||||
)
|
||||
await listProducts({ page: 2, pageSize: 10, activo: true, medioId: 5 })
|
||||
expect(capturedUrl).toContain('page=2')
|
||||
expect(capturedUrl).toContain('pageSize=10')
|
||||
expect(capturedUrl).toContain('medioId=5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProductById', () => {
|
||||
it('calls GET /api/v1/products/:id and returns detail', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1`, () => HttpResponse.json(mockDetail)),
|
||||
)
|
||||
const result = await getProductById(1)
|
||||
expect(result).toEqual(mockDetail)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProduct', () => {
|
||||
it('calls POST /api/v1/admin/products with payload', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockDetail, { status: 201 })
|
||||
}),
|
||||
)
|
||||
const req = {
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
basePrice: 100.50,
|
||||
}
|
||||
await createProduct(req)
|
||||
expect(capturedBody).toEqual(req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateProduct', () => {
|
||||
it('calls PUT /api/v1/admin/products/:id with payload', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/admin/products/1`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockDetail)
|
||||
}),
|
||||
)
|
||||
const req = { nombre: 'Modificado', basePrice: 200 }
|
||||
await updateProduct(1, req)
|
||||
expect(capturedBody).toEqual(req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deactivateProduct', () => {
|
||||
it('calls DELETE /api/v1/admin/products/:id', async () => {
|
||||
let called = false
|
||||
server.use(
|
||||
http.delete(`${API_URL}/api/v1/admin/products/1`, () => {
|
||||
called = true
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
await deactivateProduct(1)
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
})
|
||||
143
src/web/src/tests/features/products/hooks.test.ts
Normal file
143
src/web/src/tests/features/products/hooks.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useProducts } from '../../../features/products/hooks/useProducts'
|
||||
import { useCreateProduct } from '../../../features/products/hooks/useCreateProduct'
|
||||
import { useUpdateProduct } from '../../../features/products/hooks/useUpdateProduct'
|
||||
import { useDeactivateProduct } from '../../../features/products/hooks/useDeactivateProduct'
|
||||
import type { ProductListItem, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockItem: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockPaged: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function makeWrapper() {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
}
|
||||
|
||||
describe('useProducts', () => {
|
||||
it('returns paged data on success', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockPaged)
|
||||
})
|
||||
|
||||
it('returns error state on failure', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () =>
|
||||
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||
),
|
||||
)
|
||||
const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCreateProduct', () => {
|
||||
it('calls create and invalidates products queries on success', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, () =>
|
||||
HttpResponse.json(mockItem, { status: 201 }),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useCreateProduct(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate({
|
||||
nombre: 'Nuevo Producto',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
basePrice: 100,
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpdateProduct', () => {
|
||||
it('calls update and invalidates products queries on success', async () => {
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/admin/products/1`, () =>
|
||||
HttpResponse.json(mockItem),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useUpdateProduct(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate({ id: 1, data: { nombre: 'Modificado', basePrice: 200 } })
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDeactivateProduct', () => {
|
||||
it('calls deactivate and invalidates products queries on success', async () => {
|
||||
server.use(
|
||||
http.delete(`${API_URL}/api/v1/admin/products/1`, () =>
|
||||
new HttpResponse(null, { status: 204 }),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useDeactivateProduct(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate(1)
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user