UDT-008: Gestión completa de usuarios #11
7
src/web/src/features/users/api/deactivateUser.ts
Normal file
7
src/web/src/features/users/api/deactivateUser.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { UserDetail } from '../types'
|
||||
|
||||
export async function deactivateUser(id: number): Promise<UserDetail> {
|
||||
const response = await axiosClient.patch<UserDetail>(`/api/v1/users/${id}/deactivate`)
|
||||
return response.data
|
||||
}
|
||||
7
src/web/src/features/users/api/getUser.ts
Normal file
7
src/web/src/features/users/api/getUser.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { UserDetail } from '../types'
|
||||
|
||||
export async function getUser(id: number): Promise<UserDetail> {
|
||||
const response = await axiosClient.get<UserDetail>(`/api/v1/users/${id}`)
|
||||
return response.data
|
||||
}
|
||||
7
src/web/src/features/users/api/reactivateUser.ts
Normal file
7
src/web/src/features/users/api/reactivateUser.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { UserDetail } from '../types'
|
||||
|
||||
export async function reactivateUser(id: number): Promise<UserDetail> {
|
||||
const response = await axiosClient.patch<UserDetail>(`/api/v1/users/${id}/reactivate`)
|
||||
return response.data
|
||||
}
|
||||
13
src/web/src/features/users/api/updateUser.ts
Normal file
13
src/web/src/features/users/api/updateUser.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { UserDetail, UpdateUserPayload } from '../types'
|
||||
|
||||
export async function updateUser(id: number, payload: UpdateUserPayload): Promise<UserDetail> {
|
||||
const response = await axiosClient.put<UserDetail>(`/api/v1/users/${id}`, {
|
||||
nombre: payload.nombre,
|
||||
apellido: payload.apellido,
|
||||
email: payload.email,
|
||||
rol: payload.rol,
|
||||
activo: payload.activo,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
13
src/web/src/features/users/hooks/useDeactivateUser.ts
Normal file
13
src/web/src/features/users/hooks/useDeactivateUser.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { deactivateUser } from '../api/deactivateUser'
|
||||
|
||||
export function useDeactivateUser() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deactivateUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
13
src/web/src/features/users/hooks/useReactivateUser.ts
Normal file
13
src/web/src/features/users/hooks/useReactivateUser.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { reactivateUser } from '../api/reactivateUser'
|
||||
|
||||
export function useReactivateUser() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => reactivateUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
15
src/web/src/features/users/hooks/useUpdateUser.ts
Normal file
15
src/web/src/features/users/hooks/useUpdateUser.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { updateUser } from '../api/updateUser'
|
||||
import type { UpdateUserPayload } from '../types'
|
||||
|
||||
export function useUpdateUser(userId: number) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: UpdateUserPayload) => updateUser(userId, payload),
|
||||
onSuccess: () => {
|
||||
// Invalidate both the detail and the list
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
13
src/web/src/features/users/hooks/useUser.ts
Normal file
13
src/web/src/features/users/hooks/useUser.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getUser } from '../api/getUser'
|
||||
|
||||
export const userQueryKey = (id: number) => ['users', id] as const
|
||||
|
||||
export function useUser(id: number) {
|
||||
return useQuery({
|
||||
queryKey: userQueryKey(id),
|
||||
queryFn: () => getUser(id),
|
||||
staleTime: 15_000,
|
||||
enabled: id > 0,
|
||||
})
|
||||
}
|
||||
96
src/web/src/features/users/pages/UserDetailPage.tsx
Normal file
96
src/web/src/features/users/pages/UserDetailPage.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useUser } from '../hooks/useUser'
|
||||
import { useDeactivateUser } from '../hooks/useDeactivateUser'
|
||||
import { useReactivateUser } from '../hooks/useReactivateUser'
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const userId = Number(id)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: user, isLoading } = useUser(userId)
|
||||
const { mutate: deactivate, isPending: deactivating } = useDeactivateUser()
|
||||
const { mutate: reactivate, isPending: reactivating } = useReactivateUser()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<span className="text-muted-foreground">Cargando...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
Usuario no encontrado.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const busy = deactivating || reactivating
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{user.nombre} {user.apellido}
|
||||
</h1>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/usuarios')}>
|
||||
Volver
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-4 space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Usuario</span>
|
||||
<span className="font-mono">{user.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Email</span>
|
||||
<span>{user.email ?? '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Rol</span>
|
||||
<Badge variant="secondary" className="capitalize">{user.rol}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Estado</span>
|
||||
{user.activo
|
||||
? <Badge variant="secondary" className="bg-green-100 text-green-800">Activo</Badge>
|
||||
: <Badge variant="secondary" className="bg-red-100 text-red-800">Inactivo</Badge>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/usuarios/${userId}/editar`)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
|
||||
{user.activo ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => deactivate(userId)}
|
||||
>
|
||||
{deactivating ? 'Desactivando...' : 'Desactivar'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => reactivate(userId)}
|
||||
>
|
||||
{reactivating ? 'Reactivando...' : 'Reactivar'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
227
src/web/src/features/users/pages/UserEditPage.tsx
Normal file
227
src/web/src/features/users/pages/UserEditPage.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
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 { useUser } from '../hooks/useUser'
|
||||
import { useUpdateUser } from '../hooks/useUpdateUser'
|
||||
|
||||
const editSchema = z.object({
|
||||
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().min(1, 'Seleccioná un rol válido'),
|
||||
activo: z.boolean(),
|
||||
})
|
||||
|
||||
type EditFormValues = z.infer<typeof editSchema>
|
||||
|
||||
function resolveBackendError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
if (isAxiosError(err) && err.response?.data) {
|
||||
const data = err.response.data as { title?: string; error?: string; message?: string }
|
||||
if (data.title === 'last-admin-lockout' || data.error === 'last-admin-lockout') {
|
||||
return 'No podés cambiar el rol o desactivar al último administrador activo'
|
||||
}
|
||||
return data.message ?? data.error ?? 'Error al actualizar el usuario'
|
||||
}
|
||||
return 'Error al actualizar el usuario'
|
||||
}
|
||||
|
||||
export function UserEditPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const userId = Number(id)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: user, isLoading } = useUser(userId)
|
||||
const { mutate, isPending, error } = useUpdateUser(userId)
|
||||
|
||||
const form = useForm<EditFormValues>({
|
||||
resolver: zodResolver(editSchema),
|
||||
defaultValues: {
|
||||
nombre: '',
|
||||
apellido: '',
|
||||
email: '',
|
||||
rol: '',
|
||||
activo: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Prefill form when user data loads
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.reset({
|
||||
nombre: user.nombre,
|
||||
apellido: user.apellido,
|
||||
email: user.email ?? '',
|
||||
rol: user.rol,
|
||||
activo: user.activo,
|
||||
})
|
||||
}
|
||||
}, [user, form])
|
||||
|
||||
function handleSubmit(values: EditFormValues) {
|
||||
mutate(
|
||||
{
|
||||
nombre: values.nombre,
|
||||
apellido: values.apellido,
|
||||
email: values.email || null,
|
||||
rol: values.rol,
|
||||
activo: values.activo,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate('/usuarios')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const backendError = resolveBackendError(error)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<span className="text-muted-foreground">Cargando...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
Usuario no encontrado.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Editar Usuario</h1>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/usuarios')}>
|
||||
Volver
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Username — display only, not editable */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">Usuario</p>
|
||||
<p className="text-sm font-mono bg-muted rounded px-3 py-2">{user.username}</p>
|
||||
</div>
|
||||
|
||||
<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="nombre"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isPending} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apellido"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Apellido</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isPending} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email (opcional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="email" disabled={isPending} />
|
||||
</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 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="cajero">Cajero</option>
|
||||
<option value="reportes">Reportes</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="activo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-3">
|
||||
<FormControl>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={isPending}
|
||||
aria-label="Activo"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Activo</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
src/web/src/tests/features/users/UserEditPage.test.tsx
Normal file
141
src/web/src/tests/features/users/UserEditPage.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
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 { UserEditPage } from '../../../features/users/pages/UserEditPage'
|
||||
import { useAuthStore } from '../../../stores/authStore'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router-dom', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>()
|
||||
return { ...actual, useNavigate: () => mockNavigate }
|
||||
})
|
||||
|
||||
const adminUser = {
|
||||
id: 1, username: 'admin', nombre: 'Admin', rol: 'admin',
|
||||
permisos: ['administracion:usuarios:gestionar'],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
const mockUserDetail = {
|
||||
id: 5,
|
||||
username: 'cajero1',
|
||||
nombre: 'Juan',
|
||||
apellido: 'Pérez',
|
||||
email: 'j@x.com',
|
||||
rol: 'cajero',
|
||||
activo: true,
|
||||
mustChangePassword: false,
|
||||
ultimoLogin: null,
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
useAuthStore.getState().clearAuth()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderEditPage(userId = 5) {
|
||||
useAuthStore.setState({ user: adminUser })
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={[`/usuarios/${userId}/editar`]}>
|
||||
<Routes>
|
||||
<Route path="/usuarios/:id/editar" element={<UserEditPage />} />
|
||||
<Route path="/usuarios" element={<div>Users List</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('UserEditPage', () => {
|
||||
it('prefills form with user data', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
|
||||
)
|
||||
|
||||
renderEditPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||
expect(screen.getByDisplayValue('Pérez')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('j@x.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('username field is displayed but not an editable input', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
|
||||
)
|
||||
|
||||
renderEditPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('cajero1')).toBeInTheDocument())
|
||||
|
||||
// Username should NOT be an editable input
|
||||
const inputs = screen.queryAllByRole('textbox')
|
||||
const usernameInput = inputs.find((el) => (el as HTMLInputElement).value === 'cajero1')
|
||||
expect(usernameInput).toBeUndefined()
|
||||
})
|
||||
|
||||
it('submit calls PUT with correct payload then navigates to /usuarios', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
|
||||
http.put(`${API_URL}/api/v1/users/5`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json({ ...mockUserDetail, nombre: 'Pedro' })
|
||||
}),
|
||||
)
|
||||
|
||||
renderEditPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||
|
||||
// Clear and update nombre
|
||||
const nombreInput = screen.getByDisplayValue('Juan')
|
||||
await userEvent.clear(nombreInput)
|
||||
await userEvent.type(nombreInput, 'Pedro')
|
||||
|
||||
// Submit
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar|actualizar|save/i }))
|
||||
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/usuarios'))
|
||||
expect(capturedBody).toMatchObject({ nombre: 'Pedro' })
|
||||
})
|
||||
|
||||
it('shows last-admin-lockout error message on 400', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
|
||||
http.put(`${API_URL}/api/v1/users/5`, () =>
|
||||
HttpResponse.json(
|
||||
{ title: 'last-admin-lockout', status: 400 },
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
renderEditPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar|actualizar|save/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/último administrador|last.admin.lockout/i)).toBeInTheDocument(),
|
||||
)
|
||||
|
||||
// Should NOT navigate
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
35
src/web/src/tests/features/users/getUser.test.ts
Normal file
35
src/web/src/tests/features/users/getUser.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { getUser } from '../../../features/users/api/getUser'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockDetail = {
|
||||
id: 5,
|
||||
username: 'cajero1',
|
||||
nombre: 'Juan',
|
||||
apellido: 'Pérez',
|
||||
email: 'j@x.com',
|
||||
rol: 'cajero',
|
||||
activo: true,
|
||||
mustChangePassword: false,
|
||||
ultimoLogin: '2026-04-10T10:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('getUser api client', () => {
|
||||
it('calls GET /api/v1/users/:id and returns UserDetail', async () => {
|
||||
server.use(http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockDetail)))
|
||||
const result = await getUser(5)
|
||||
expect(result.id).toBe(5)
|
||||
expect(result.username).toBe('cajero1')
|
||||
expect(result.mustChangePassword).toBe(false)
|
||||
})
|
||||
})
|
||||
48
src/web/src/tests/features/users/updateUser.test.ts
Normal file
48
src/web/src/tests/features/users/updateUser.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { updateUser } from '../../../features/users/api/updateUser'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockDetail = {
|
||||
id: 5,
|
||||
username: 'cajero1',
|
||||
nombre: 'Pedro',
|
||||
apellido: 'Gómez',
|
||||
email: 'new@x.com',
|
||||
rol: 'cajero',
|
||||
activo: true,
|
||||
mustChangePassword: false,
|
||||
ultimoLogin: null,
|
||||
fechaModificacion: '2026-04-15T18:00:00Z',
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('updateUser api client', () => {
|
||||
it('calls PUT /api/v1/users/:id with payload and returns updated UserDetail', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/users/5`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockDetail)
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await updateUser(5, {
|
||||
nombre: 'Pedro',
|
||||
apellido: 'Gómez',
|
||||
email: 'new@x.com',
|
||||
rol: 'cajero',
|
||||
activo: true,
|
||||
})
|
||||
|
||||
expect(result.nombre).toBe('Pedro')
|
||||
expect(capturedBody).toMatchObject({ nombre: 'Pedro', apellido: 'Gómez', email: 'new@x.com' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user