UDT-003: Registro de Usuarios (admin-only) + fix JWT claim mapping #4
@@ -5,9 +5,11 @@ import {
|
||||
Calculator,
|
||||
Zap,
|
||||
Settings,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
@@ -36,6 +38,8 @@ const navItems: NavItem[] = [
|
||||
|
||||
export function SidebarNav() {
|
||||
const { pathname } = useLocation()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isAdmin = user?.rol === 'admin'
|
||||
|
||||
return (
|
||||
<aside className="flex h-full flex-col bg-background border-r border-border">
|
||||
@@ -79,6 +83,29 @@ export function SidebarNav() {
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Admin-only section */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
Administración
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/usuarios/nuevo"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname === '/usuarios/nuevo'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<UserPlus className="h-4 w-4 shrink-0" />
|
||||
<span>Crear Usuario</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
|
||||
25
src/web/src/features/users/api/createUser.ts
Normal file
25
src/web/src/features/users/api/createUser.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { axiosClient } from '../../../api/axiosClient'
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string
|
||||
password: string
|
||||
nombre: string
|
||||
apellido: string
|
||||
email?: string
|
||||
rol: string
|
||||
}
|
||||
|
||||
export interface CreatedUserDto {
|
||||
id: number
|
||||
username: string
|
||||
nombre: string
|
||||
apellido: string
|
||||
email: string | null
|
||||
rol: string
|
||||
activo: boolean
|
||||
}
|
||||
|
||||
export async function createUser(payload: CreateUserRequest): Promise<CreatedUserDto> {
|
||||
const response = await axiosClient.post<CreatedUserDto>('/api/v1/users', payload)
|
||||
return response.data
|
||||
}
|
||||
225
src/web/src/features/users/components/UserForm.tsx
Normal file
225
src/web/src/features/users/components/UserForm.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { useCreateUser } from '../hooks/useCreateUser'
|
||||
import type { CreatedUserDto } from '../api/createUser'
|
||||
|
||||
const ROL_OPTIONS = ['admin', 'vendedor', 'tasador', 'consulta'] as const
|
||||
|
||||
const userFormSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, 'Mínimo 3 caracteres')
|
||||
.max(50, 'Máximo 50 caracteres'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Mínimo 8 caracteres')
|
||||
.regex(/[a-zA-Z]/, 'Debe contener al menos una letra')
|
||||
.regex(/[0-9]/, 'Debe contener al menos un dígito'),
|
||||
nombre: z.string().min(1, 'El nombre es requerido'),
|
||||
apellido: z.string().min(1, 'El apellido es requerido'),
|
||||
email: z.string().email('Email inválido').optional().or(z.literal('')),
|
||||
rol: z
|
||||
.string({ required_error: 'Seleccioná un rol válido' })
|
||||
.refine((v): v is (typeof ROL_OPTIONS)[number] => (ROL_OPTIONS as readonly string[]).includes(v), {
|
||||
message: 'Seleccioná un rol válido',
|
||||
}),
|
||||
})
|
||||
|
||||
type UserFormValues = z.infer<typeof userFormSchema>
|
||||
|
||||
interface UserFormProps {
|
||||
onSuccess?: (user: CreatedUserDto) => void
|
||||
}
|
||||
|
||||
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 }
|
||||
if (data.error === 'username_taken') {
|
||||
return data.message ?? 'El usuario ya existe'
|
||||
}
|
||||
return data.error ?? 'Error al crear el usuario'
|
||||
}
|
||||
return 'Error al crear el usuario'
|
||||
}
|
||||
|
||||
export function UserForm({ onSuccess }: UserFormProps) {
|
||||
const { mutate, isPending, error } = useCreateUser()
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
nombre: '',
|
||||
apellido: '',
|
||||
email: '',
|
||||
rol: '',
|
||||
},
|
||||
})
|
||||
|
||||
function handleSubmit(values: UserFormValues) {
|
||||
mutate(
|
||||
{
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
nombre: values.nombre,
|
||||
apellido: values.apellido,
|
||||
email: values.email || undefined,
|
||||
rol: values.rol,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onSuccess?.(data)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const backendError = resolveBackendError(error)
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usuario</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
disabled={isPending}
|
||||
placeholder="Nombre de usuario"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isPending}
|
||||
placeholder="Mínimo 8 chars, letra y dígito"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nombre"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" disabled={isPending} placeholder="Nombre" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apellido"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Apellido</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" disabled={isPending} placeholder="Apellido" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email (opcional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
disabled={isPending}
|
||||
placeholder="correo@ejemplo.com"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rol</FormLabel>
|
||||
<FormControl>
|
||||
<select
|
||||
{...field}
|
||||
disabled={isPending}
|
||||
aria-label="Rol"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Seleccioná un rol</option>
|
||||
{ROL_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r.charAt(0).toUpperCase() + r.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? 'Creando...' : 'Crear usuario'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
9
src/web/src/features/users/hooks/useCreateUser.ts
Normal file
9
src/web/src/features/users/hooks/useCreateUser.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { createUser } from '../api/createUser'
|
||||
import type { CreateUserRequest } from '../api/createUser'
|
||||
|
||||
export function useCreateUser() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateUserRequest) => createUser(payload),
|
||||
})
|
||||
}
|
||||
42
src/web/src/features/users/pages/CreateUserPage.tsx
Normal file
42
src/web/src/features/users/pages/CreateUserPage.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { UserForm } from '../components/UserForm'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import type { CreatedUserDto } from '../api/createUser'
|
||||
|
||||
export function CreateUserPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
// Guard: only admins can access this page
|
||||
if (!user || user.rol !== 'admin') {
|
||||
void navigate('/', { replace: true })
|
||||
return null
|
||||
}
|
||||
|
||||
function handleSuccess(_created: CreatedUserDto) {
|
||||
void navigate('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl">Crear Usuario</CardTitle>
|
||||
<CardDescription>
|
||||
Completá los datos para registrar un nuevo usuario en el sistema.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserForm onSuccess={handleSuccess} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
import { LoginPage } from './features/auth/pages/LoginPage'
|
||||
import { CreateUserPage } from './features/users/pages/CreateUserPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -44,6 +45,16 @@ export function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/usuarios/nuevo"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedLayout>
|
||||
<CreateUserPage />
|
||||
</ProtectedLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
202
src/web/src/tests/features/users/UserForm.test.tsx
Normal file
202
src/web/src/tests/features/users/UserForm.test.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { UserForm } from '../../../features/users/components/UserForm'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockCreatedUser = {
|
||||
id: 42,
|
||||
username: 'jdoe',
|
||||
nombre: 'Juan',
|
||||
apellido: 'Doe',
|
||||
email: null,
|
||||
rol: 'vendedor',
|
||||
activo: true,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderForm(onSuccess = vi.fn()) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<UserForm onSuccess={onSuccess} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('UserForm — Zod validation', () => {
|
||||
it('shows error when username is too short (< 3 chars)', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'ab')
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/mínimo 3 caracteres/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when username exceeds 50 chars', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'a'.repeat(51))
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/máximo 50 caracteres/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when password is too short (< 8 chars)', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Ab1')
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/mínimo 8 caracteres/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when password has no letter', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), '12345678')
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/debe contener al menos una letra/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when password has no digit', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'abcdefgh')
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/debe contener al menos un dígito/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when rol is not in whitelist', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
// Fill valid fields, leave rol empty (default placeholder)
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'jdoe123')
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
||||
await user.type(screen.getByLabelText(/apellido/i), 'Doe')
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/seleccioná un rol válido/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts optional empty email', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
||||
return HttpResponse.json(mockCreatedUser, { status: 201 })
|
||||
}),
|
||||
)
|
||||
|
||||
const onSuccess = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderForm(onSuccess)
|
||||
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'jdoe123')
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
||||
await user.type(screen.getByLabelText(/apellido/i), 'Doe')
|
||||
// Select rol via combobox
|
||||
await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor')
|
||||
// email left empty — valid
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(mockCreatedUser)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserForm — submit and backend error display', () => {
|
||||
it('calls mutation on valid submit and invokes onSuccess callback', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
||||
return HttpResponse.json(mockCreatedUser, { status: 201 })
|
||||
}),
|
||||
)
|
||||
|
||||
const onSuccess = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderForm(onSuccess)
|
||||
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'jdoe123')
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
||||
await user.type(screen.getByLabelText(/apellido/i), 'Doe')
|
||||
await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(mockCreatedUser)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows backend 409 username_taken error in alert', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
||||
return HttpResponse.json(
|
||||
{ error: 'username_taken', message: 'El usuario ya existe' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'existing')
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
||||
await user.type(screen.getByLabelText(/apellido/i), 'Doe')
|
||||
await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/usuario ya existe/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
111
src/web/src/tests/features/users/useCreateUser.test.ts
Normal file
111
src/web/src/tests/features/users/useCreateUser.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createElement } from 'react'
|
||||
import { useCreateUser } from '../../../features/users/hooks/useCreateUser'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockCreatedUser = {
|
||||
id: 42,
|
||||
username: 'jdoe',
|
||||
nombre: 'Juan',
|
||||
apellido: 'Doe',
|
||||
email: 'jdoe@example.com',
|
||||
rol: 'vendedor',
|
||||
activo: true,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
function makeWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
describe('useCreateUser', () => {
|
||||
it('mutation succeeds (201) and resolves with created user', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
||||
return HttpResponse.json(mockCreatedUser, { status: 201 })
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useCreateUser(), { wrapper: makeWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({
|
||||
username: 'jdoe',
|
||||
password: 'Secret1234',
|
||||
nombre: 'Juan',
|
||||
apellido: 'Doe',
|
||||
email: 'jdoe@example.com',
|
||||
rol: 'vendedor',
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockCreatedUser)
|
||||
})
|
||||
|
||||
it('mutation fails with 409 username_taken', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
||||
return HttpResponse.json(
|
||||
{ error: 'username_taken', message: 'El usuario ya existe' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useCreateUser(), { wrapper: makeWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({
|
||||
username: 'existing',
|
||||
password: 'Secret1234',
|
||||
nombre: 'Juan',
|
||||
apellido: 'Doe',
|
||||
rol: 'vendedor',
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
expect(result.current.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('mutation fails with 400 validation error', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
||||
return HttpResponse.json(
|
||||
{ errors: { username: ['Username is required'] } },
|
||||
{ status: 400 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useCreateUser(), { wrapper: makeWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({
|
||||
username: '',
|
||||
password: 'Secret1234',
|
||||
nombre: 'Juan',
|
||||
apellido: 'Doe',
|
||||
rol: 'vendedor',
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
expect(result.current.error).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user