feat: PRD-001 ProductType (flags + multimedia) #38
@@ -16,6 +16,7 @@ import {
|
|||||||
Columns3,
|
Columns3,
|
||||||
Store,
|
Store,
|
||||||
Tag,
|
Tag,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -75,6 +76,12 @@ const adminItems: NavItem[] = [
|
|||||||
icon: Tag,
|
icon: Tag,
|
||||||
requiredPermission: 'catalogo:rubros:gestionar',
|
requiredPermission: 'catalogo:rubros:gestionar',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Tipos de Producto',
|
||||||
|
href: '/admin/product-types',
|
||||||
|
icon: Layers,
|
||||||
|
requiredPermission: 'catalogo:tipos:gestionar',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarNavProps {
|
interface SidebarNavProps {
|
||||||
|
|||||||
12
src/web/src/features/product-types/api/createProductType.ts
Normal file
12
src/web/src/features/product-types/api/createProductType.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { CreateProductTypeRequest, ProductTypeDetail } from '../types'
|
||||||
|
|
||||||
|
export async function createProductType(
|
||||||
|
payload: CreateProductTypeRequest,
|
||||||
|
): Promise<ProductTypeDetail> {
|
||||||
|
const response = await axiosClient.post<ProductTypeDetail>(
|
||||||
|
'/api/v1/admin/product-types',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function deactivateProductType(id: number): Promise<void> {
|
||||||
|
await axiosClient.delete(`/api/v1/admin/product-types/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { ProductTypeDetail } from '../types'
|
||||||
|
|
||||||
|
export async function getProductTypeById(id: number): Promise<ProductTypeDetail> {
|
||||||
|
const response = await axiosClient.get<ProductTypeDetail>(`/api/v1/product-types/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
12
src/web/src/features/product-types/api/listProductTypes.ts
Normal file
12
src/web/src/features/product-types/api/listProductTypes.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { ListProductTypesParams, PagedResult, ProductTypeListItem } from '../types'
|
||||||
|
|
||||||
|
export async function listProductTypes(
|
||||||
|
params?: ListProductTypesParams,
|
||||||
|
): Promise<PagedResult<ProductTypeListItem>> {
|
||||||
|
const response = await axiosClient.get<PagedResult<ProductTypeListItem>>(
|
||||||
|
'/api/v1/product-types',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
13
src/web/src/features/product-types/api/updateProductType.ts
Normal file
13
src/web/src/features/product-types/api/updateProductType.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { UpdateProductTypeRequest, ProductTypeDetail } from '../types'
|
||||||
|
|
||||||
|
export async function updateProductType(
|
||||||
|
id: number,
|
||||||
|
payload: UpdateProductTypeRequest,
|
||||||
|
): Promise<ProductTypeDetail> {
|
||||||
|
const response = await axiosClient.put<ProductTypeDetail>(
|
||||||
|
`/api/v1/admin/product-types/${id}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createProductType } from '../api/createProductType'
|
||||||
|
import type { CreateProductTypeRequest } from '../types'
|
||||||
|
|
||||||
|
export function useCreateProductType() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateProductTypeRequest) => createProductType(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['product-types'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { deactivateProductType } from '../api/deactivateProductType'
|
||||||
|
|
||||||
|
export function useDeactivateProductType() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deactivateProductType(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['product-types'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
10
src/web/src/features/product-types/hooks/useProductTypes.ts
Normal file
10
src/web/src/features/product-types/hooks/useProductTypes.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { listProductTypes } from '../api/listProductTypes'
|
||||||
|
import type { ListProductTypesParams } from '../types'
|
||||||
|
|
||||||
|
export function useProductTypes(params?: ListProductTypesParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['product-types', params],
|
||||||
|
queryFn: () => listProductTypes(params),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { updateProductType } from '../api/updateProductType'
|
||||||
|
import type { UpdateProductTypeRequest } from '../types'
|
||||||
|
|
||||||
|
export function useUpdateProductType() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: UpdateProductTypeRequest }) =>
|
||||||
|
updateProductType(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['product-types'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
3
src/web/src/features/product-types/index.ts
Normal file
3
src/web/src/features/product-types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// PRD-001 — product-types feature public API
|
||||||
|
export { ProductTypesPage } from './pages/ProductTypesPage'
|
||||||
|
export type { ProductTypeListItem, ProductTypeDetail, CreateProductTypeRequest, UpdateProductTypeRequest } from './types'
|
||||||
141
src/web/src/features/product-types/pages/ProductTypesPage.tsx
Normal file
141
src/web/src/features/product-types/pages/ProductTypesPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AlertCircle, Plus } from 'lucide-react'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
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 { useProductTypes } from '../hooks/useProductTypes'
|
||||||
|
import { useCreateProductType } from '../hooks/useCreateProductType'
|
||||||
|
import { useUpdateProductType } from '../hooks/useUpdateProductType'
|
||||||
|
import { useDeactivateProductType } from '../hooks/useDeactivateProductType'
|
||||||
|
import type { ProductTypeListItem } from '../types'
|
||||||
|
import type { CreateProductTypeRequest } from '../types'
|
||||||
|
|
||||||
|
export function ProductTypesPage() {
|
||||||
|
const [formError, setFormError] = useState<unknown>(null)
|
||||||
|
|
||||||
|
const { data: paged, isLoading, isError } = useProductTypes({ activo: true })
|
||||||
|
const { mutateAsync: createProductType, isPending: creating } = useCreateProductType()
|
||||||
|
const { mutateAsync: updateProductType, isPending: updating } = useUpdateProductType()
|
||||||
|
const { mutateAsync: deactivateProductType } = useDeactivateProductType()
|
||||||
|
|
||||||
|
async function handleCreate(payload: CreateProductTypeRequest) {
|
||||||
|
try {
|
||||||
|
setFormError(null)
|
||||||
|
await createProductType(payload)
|
||||||
|
toast.success('Tipo de producto creado')
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err)
|
||||||
|
if (isAxiosError(err) && err.response?.status === 409) {
|
||||||
|
toast.error('Ya existe un tipo de producto con ese nombre')
|
||||||
|
} else {
|
||||||
|
toast.error('Error al crear tipo de producto')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeactivate(id: number) {
|
||||||
|
try {
|
||||||
|
await deactivateProductType(id)
|
||||||
|
toast.success('Tipo de producto desactivado')
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al desactivar tipo de producto')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 tipos de producto.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Tipos de Producto</h1>
|
||||||
|
<CanPerform permission="catalogo:tipos:gestionar">
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo Tipo
|
||||||
|
</Button>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{isAxiosError(formError) ? formError.message : 'Error inesperado'}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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">Duración</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Texto</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Categoría</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Bundle</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Imágenes</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((pt: ProductTypeListItem) => (
|
||||||
|
<tr key={pt.id} className="border-b last:border-0 hover:bg-muted/25">
|
||||||
|
<td className="px-4 py-2 font-medium">{pt.nombre}</td>
|
||||||
|
<td className="px-4 py-2">{pt.hasDuration ? 'Sí' : 'No'}</td>
|
||||||
|
<td className="px-4 py-2">{pt.requiresText ? 'Sí' : 'No'}</td>
|
||||||
|
<td className="px-4 py-2">{pt.requiresCategory ? 'Sí' : 'No'}</td>
|
||||||
|
<td className="px-4 py-2">{pt.isBundle ? 'Sí' : 'No'}</td>
|
||||||
|
<td className="px-4 py-2">{pt.allowImages ? 'Sí' : 'No'}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={pt.isActive ? 'text-green-600' : 'text-red-500'}>
|
||||||
|
{pt.isActive ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<CanPerform permission="catalogo:tipos:gestionar">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeactivate(pt.id)}
|
||||||
|
disabled={!pt.isActive}
|
||||||
|
>
|
||||||
|
Desactivar
|
||||||
|
</Button>
|
||||||
|
</CanPerform>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{paged?.items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
|
No hay tipos de producto.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/web/src/features/product-types/types.ts
Normal file
69
src/web/src/features/product-types/types.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// PRD-001 — shared types for product-types feature
|
||||||
|
|
||||||
|
export interface ProductTypeListItem {
|
||||||
|
id: number
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductTypeDetail {
|
||||||
|
id: number
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
maxImages: number | null
|
||||||
|
maxImageSizeMB: number | null
|
||||||
|
maxImageWidth: number | null
|
||||||
|
maxImageHeight: number | null
|
||||||
|
isActive: boolean
|
||||||
|
fechaCreacion: string
|
||||||
|
fechaModificacion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductTypeRequest {
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
maxImages?: number | null
|
||||||
|
maxImageSizeMB?: number | null
|
||||||
|
maxImageWidth?: number | null
|
||||||
|
maxImageHeight?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductTypeRequest {
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
maxImages?: number | null
|
||||||
|
maxImageSizeMB?: number | null
|
||||||
|
maxImageWidth?: number | null
|
||||||
|
maxImageHeight?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListProductTypesParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
activo?: boolean | null
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPunto
|
|||||||
import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
|
import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
|
||||||
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||||
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
||||||
|
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||||
@@ -309,6 +310,16 @@ export function AppRoutes() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ProductTypes routes — PRD-001 */}
|
||||||
|
<Route
|
||||||
|
path="/admin/product-types"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['catalogo:tipos:gestionar']}>
|
||||||
|
<ProductTypesPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
137
src/web/src/tests/features/product-types/api.test.ts
Normal file
137
src/web/src/tests/features/product-types/api.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { listProductTypes } from '../../../features/product-types/api/listProductTypes'
|
||||||
|
import { getProductTypeById } from '../../../features/product-types/api/getProductTypeById'
|
||||||
|
import { createProductType } from '../../../features/product-types/api/createProductType'
|
||||||
|
import { updateProductType } from '../../../features/product-types/api/updateProductType'
|
||||||
|
import { deactivateProductType } from '../../../features/product-types/api/deactivateProductType'
|
||||||
|
import type { ProductTypeListItem, ProductTypeDetail, PagedResult } from '../../../features/product-types/types'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockListItem: ProductTypeListItem = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Avisos Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: true,
|
||||||
|
requiresCategory: true,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: true,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDetail: ProductTypeDetail = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Avisos Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: true,
|
||||||
|
requiresCategory: true,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: true,
|
||||||
|
maxImages: 5,
|
||||||
|
maxImageSizeMB: 2.5,
|
||||||
|
maxImageWidth: 800,
|
||||||
|
maxImageHeight: 600,
|
||||||
|
isActive: true,
|
||||||
|
fechaCreacion: '2026-04-19T00:00:00Z',
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPaged: PagedResult<ProductTypeListItem> = {
|
||||||
|
items: [mockListItem],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
describe('listProductTypes', () => {
|
||||||
|
it('calls GET /api/v1/product-types and returns paged result', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
const result = await listProductTypes()
|
||||||
|
expect(result).toEqual(mockPaged)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes query params when provided', async () => {
|
||||||
|
let capturedUrl = ''
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json(mockPaged)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await listProductTypes({ page: 2, pageSize: 10, activo: true, search: 'test' })
|
||||||
|
expect(capturedUrl).toContain('page=2')
|
||||||
|
expect(capturedUrl).toContain('pageSize=10')
|
||||||
|
expect(capturedUrl).toContain('search=test')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getProductTypeById', () => {
|
||||||
|
it('calls GET /api/v1/product-types/:id and returns detail', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types/1`, () => HttpResponse.json(mockDetail)),
|
||||||
|
)
|
||||||
|
const result = await getProductTypeById(1)
|
||||||
|
expect(result).toEqual(mockDetail)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createProductType', () => {
|
||||||
|
it('calls POST /api/v1/admin/product-types with payload', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/product-types`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json(mockDetail, { status: 201 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const req = {
|
||||||
|
nombre: 'Avisos Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: true,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: true,
|
||||||
|
}
|
||||||
|
await createProductType(req)
|
||||||
|
expect(capturedBody).toEqual(req)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateProductType', () => {
|
||||||
|
it('calls PUT /api/v1/admin/product-types/:id with payload', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/admin/product-types/1`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json(mockDetail)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const req = { nombre: 'Modificado', hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, allowImages: false }
|
||||||
|
await updateProductType(1, req)
|
||||||
|
expect(capturedBody).toEqual(req)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deactivateProductType', () => {
|
||||||
|
it('calls DELETE /api/v1/admin/product-types/:id', async () => {
|
||||||
|
let called = false
|
||||||
|
server.use(
|
||||||
|
http.delete(`${API_URL}/api/v1/admin/product-types/1`, () => {
|
||||||
|
called = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await deactivateProductType(1)
|
||||||
|
expect(called).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
148
src/web/src/tests/features/product-types/hooks.test.ts
Normal file
148
src/web/src/tests/features/product-types/hooks.test.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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 { useProductTypes } from '../../../features/product-types/hooks/useProductTypes'
|
||||||
|
import { useCreateProductType } from '../../../features/product-types/hooks/useCreateProductType'
|
||||||
|
import { useUpdateProductType } from '../../../features/product-types/hooks/useUpdateProductType'
|
||||||
|
import { useDeactivateProductType } from '../../../features/product-types/hooks/useDeactivateProductType'
|
||||||
|
import type { ProductTypeListItem, PagedResult } from '../../../features/product-types/types'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockItem: ProductTypeListItem = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Avisos',
|
||||||
|
hasDuration: false,
|
||||||
|
requiresText: false,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPaged: PagedResult<ProductTypeListItem> = {
|
||||||
|
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('useProductTypes', () => {
|
||||||
|
it('returns paged data on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
const { result } = renderHook(() => useProductTypes(), { 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/product-types`, () =>
|
||||||
|
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { result } = renderHook(() => useProductTypes(), { wrapper: makeWrapper() })
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useCreateProductType', () => {
|
||||||
|
it('calls create and invalidates product-types queries on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/product-types`, () =>
|
||||||
|
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(() => useCreateProductType(), { wrapper })
|
||||||
|
await act(async () => {
|
||||||
|
result.current.mutate({
|
||||||
|
nombre: 'Nuevo',
|
||||||
|
hasDuration: false,
|
||||||
|
requiresText: false,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-types'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useUpdateProductType', () => {
|
||||||
|
it('calls update and invalidates product-types queries on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/admin/product-types/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(() => useUpdateProductType(), { wrapper })
|
||||||
|
await act(async () => {
|
||||||
|
result.current.mutate({
|
||||||
|
id: 1,
|
||||||
|
data: { nombre: 'Modificado', hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, allowImages: false },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-types'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useDeactivateProductType', () => {
|
||||||
|
it('calls deactivate and invalidates product-types queries on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.delete(`${API_URL}/api/v1/admin/product-types/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(() => useDeactivateProductType(), { wrapper })
|
||||||
|
await act(async () => {
|
||||||
|
result.current.mutate(1)
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-types'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user