feat(web): UserDetailPage + UserEditPage — get/update/deactivate/reactivate hooks y pages [UDT-008]
This commit is contained in:
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