UDT-008: Gestión completa de usuarios #11
15
src/web/src/features/users/api/listUsers.ts
Normal file
15
src/web/src/features/users/api/listUsers.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { PagedResult, UserListItem, UsuariosQuery } from '../types'
|
||||||
|
|
||||||
|
export async function listUsers(query: UsuariosQuery): Promise<PagedResult<UserListItem>> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
if (query.page !== undefined) params.set('page', String(query.page))
|
||||||
|
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
|
||||||
|
if (query.rol !== undefined && query.rol !== '') params.set('rol', query.rol)
|
||||||
|
if (query.activo !== undefined) params.set('activo', String(query.activo))
|
||||||
|
if (query.search !== undefined && query.search !== '') params.set('search', query.search)
|
||||||
|
|
||||||
|
const response = await axiosClient.get<PagedResult<UserListItem>>('/api/v1/users', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
69
src/web/src/features/users/components/UsersFilters.tsx
Normal file
69
src/web/src/features/users/components/UsersFilters.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
||||||
|
|
||||||
|
interface UsersFiltersProps {
|
||||||
|
onRolChange: (rol: string) => void
|
||||||
|
onActivoChange: (activo: boolean | undefined) => void
|
||||||
|
/** Called with the debounced search string (300ms) */
|
||||||
|
onSearchChange: (search: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROL_OPTIONS = [
|
||||||
|
{ value: '', label: 'Todos los roles' },
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'cajero', label: 'Cajero' },
|
||||||
|
{ value: 'reportes', label: 'Reportes' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function UsersFilters({ onRolChange, onActivoChange, onSearchChange }: UsersFiltersProps) {
|
||||||
|
const [searchRaw, setSearchRaw] = useState('')
|
||||||
|
const debouncedSearch = useDebouncedValue(searchRaw, 300)
|
||||||
|
|
||||||
|
// Propagate debounced search to parent
|
||||||
|
useEffect(() => {
|
||||||
|
onSearchChange(debouncedSearch)
|
||||||
|
}, [debouncedSearch, onSearchChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3 items-center mb-4">
|
||||||
|
{/* Search input */}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por usuario, nombre, email..."
|
||||||
|
value={searchRaw}
|
||||||
|
onChange={(e) => setSearchRaw(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
aria-label="Buscar usuarios"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Rol select */}
|
||||||
|
<select
|
||||||
|
aria-label="Rol"
|
||||||
|
onChange={(e) => onRolChange(e.target.value)}
|
||||||
|
className="flex h-9 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"
|
||||||
|
>
|
||||||
|
{ROL_OPTIONS.map((r) => (
|
||||||
|
<option key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Activo filter */}
|
||||||
|
<select
|
||||||
|
aria-label="Estado"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
if (v === '') onActivoChange(undefined)
|
||||||
|
else onActivoChange(v === 'true')
|
||||||
|
}}
|
||||||
|
className="flex h-9 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"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="true">Activos</option>
|
||||||
|
<option value="false">Inactivos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
src/web/src/features/users/components/UsersTable.tsx
Normal file
73
src/web/src/features/users/components/UsersTable.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { UserListItem } from '../types'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
interface UsersTableProps {
|
||||||
|
rows: UserListItem[]
|
||||||
|
onRowClick: (user: UserListItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('es-AR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersTable({ rows, onRowClick }: UsersTableProps) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Sin resultados — no se encontraron usuarios con los filtros seleccionados.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/50">
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Usuario</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Nombre</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Email</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Rol</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Estado</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Último login</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((u) => (
|
||||||
|
<tr
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => onRowClick(u)}
|
||||||
|
className="border-b border-border last:border-0 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{u.username}</td>
|
||||||
|
<td className="px-4 py-3">{`${u.nombre} ${u.apellido}`}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{u.email ?? '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{u.rol}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{u.activo ? (
|
||||||
|
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Activo
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||||
|
Inactivo
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{formatDate(u.ultimoLogin)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/web/src/features/users/hooks/useUsersList.ts
Normal file
13
src/web/src/features/users/hooks/useUsersList.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { listUsers } from '../api/listUsers'
|
||||||
|
import type { UsuariosQuery } from '../types'
|
||||||
|
|
||||||
|
export const usersListQueryKey = (query: UsuariosQuery) => ['users', 'list', query] as const
|
||||||
|
|
||||||
|
export function useUsersList(query: UsuariosQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: usersListQueryKey(query),
|
||||||
|
queryFn: () => listUsers(query),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
119
src/web/src/features/users/pages/UsersListPage.tsx
Normal file
119
src/web/src/features/users/pages/UsersListPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { UsersTable } from '../components/UsersTable'
|
||||||
|
import { UsersFilters } from '../components/UsersFilters'
|
||||||
|
import { useUsersList } from '../hooks/useUsersList'
|
||||||
|
import type { UserListItem } from '../types'
|
||||||
|
|
||||||
|
export function UsersListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [rol, setRol] = useState<string>('')
|
||||||
|
const [activo, setActivo] = useState<boolean | undefined>(undefined)
|
||||||
|
const [search, setSearch] = useState<string>('')
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
...(rol ? { rol } : {}),
|
||||||
|
...(activo !== undefined ? { activo } : {}),
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = useUsersList(query)
|
||||||
|
|
||||||
|
const handleRolChange = useCallback(
|
||||||
|
(newRol: string) => {
|
||||||
|
setRol(newRol)
|
||||||
|
setPage(1)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleActivoChange = useCallback(
|
||||||
|
(newActivo: boolean | undefined) => {
|
||||||
|
setActivo(newActivo)
|
||||||
|
setPage(1)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(newSearch: string) => {
|
||||||
|
setSearch(newSearch)
|
||||||
|
setPage(1)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(user: UserListItem) => {
|
||||||
|
navigate(`/usuarios/${user.id}/editar`)
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1
|
||||||
|
const hasPrev = page > 1
|
||||||
|
const hasNext = page < totalPages
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Usuarios</h1>
|
||||||
|
<Button onClick={() => navigate('/usuarios/nuevo')} size="sm">
|
||||||
|
Nuevo usuario
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UsersFilters
|
||||||
|
onRolChange={handleRolChange}
|
||||||
|
onActivoChange={handleActivoChange}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<UsersTable rows={data?.items ?? []} onRowClick={handleRowClick} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data ? `${data.total} usuario${data.total !== 1 ? 's' : ''}` : ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
aria-label="Anterior"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<span className="flex items-center px-2 text-sm text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
aria-label="Siguiente"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/web/src/features/users/types.ts
Normal file
48
src/web/src/features/users/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// UDT-008 — shared types for users feature
|
||||||
|
|
||||||
|
export interface UserListItem {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nombre: string
|
||||||
|
apellido: string
|
||||||
|
email: string | null
|
||||||
|
rol: string
|
||||||
|
activo: boolean
|
||||||
|
ultimoLogin: string | null // ISO datetime or null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDetail {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nombre: string
|
||||||
|
apellido: string
|
||||||
|
email: string | null
|
||||||
|
rol: string
|
||||||
|
activo: boolean
|
||||||
|
mustChangePassword: boolean
|
||||||
|
ultimoLogin: string | null
|
||||||
|
fechaModificacion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsuariosQuery {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
rol?: string
|
||||||
|
activo?: boolean
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserPayload {
|
||||||
|
nombre: string
|
||||||
|
apellido: string
|
||||||
|
email: string | null
|
||||||
|
rol: string
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
17
src/web/src/hooks/useDebouncedValue.ts
Normal file
17
src/web/src/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a debounced version of the value.
|
||||||
|
* The debounced value only updates after `delay` ms have elapsed
|
||||||
|
* since the last change.
|
||||||
|
*/
|
||||||
|
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||||
|
const [debounced, setDebounced] = useState<T>(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebounced(value), delay)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [value, delay])
|
||||||
|
|
||||||
|
return debounced
|
||||||
|
}
|
||||||
227
src/web/src/tests/features/users/UsersListPage.test.tsx
Normal file
227
src/web/src/tests/features/users/UsersListPage.test.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor, within } 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 { UsersListPage } from '../../../features/users/pages/UsersListPage'
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeItems(n: number) {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
username: `user${i + 1}`,
|
||||||
|
nombre: `Nombre${i + 1}`,
|
||||||
|
apellido: `Apellido${i + 1}`,
|
||||||
|
email: `user${i + 1}@test.com`,
|
||||||
|
rol: 'cajero',
|
||||||
|
activo: true,
|
||||||
|
ultimoLogin: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
useAuthStore.setState({ user: adminUser })
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={['/usuarios']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/usuarios" element={<UsersListPage />} />
|
||||||
|
<Route path="/usuarios/:id/editar" element={<div>Edit Page</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UsersListPage', () => {
|
||||||
|
it('renders 5 rows when API returns 5 items', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
|
||||||
|
// All 5 usernames visible
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
expect(screen.getByText(`user${i}`)).toBeInTheDocument()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when items is empty', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/sin resultados|no se encontraron/i)).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prev button disabled on first page', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const prevBtn = screen.getByRole('button', { name: /anterior|prev/i })
|
||||||
|
expect(prevBtn).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('next button disabled when on last page', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const nextBtn = screen.getByRole('button', { name: /siguiente|next/i })
|
||||||
|
expect(nextBtn).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('next button enabled when more pages exist, click requests page 2', async () => {
|
||||||
|
const requests: string[] = []
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
requests.push(request.url)
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const page = parseInt(url.searchParams.get('page') ?? '1')
|
||||||
|
return HttpResponse.json({
|
||||||
|
items: makeItems(3),
|
||||||
|
page,
|
||||||
|
pageSize: 3,
|
||||||
|
total: 6,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
useAuthStore.setState({ user: adminUser })
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<UsersListPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const nextBtn = screen.getByRole('button', { name: /siguiente|next/i })
|
||||||
|
expect(nextBtn).not.toBeDisabled()
|
||||||
|
|
||||||
|
await userEvent.click(nextBtn)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const page2Req = requests.find((u) => u.includes('page=2'))
|
||||||
|
expect(page2Req).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selecting rol filter adds querystring rol', async () => {
|
||||||
|
const requests: string[] = []
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
requests.push(request.url)
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(requests.length).toBeGreaterThan(0))
|
||||||
|
|
||||||
|
const rolSelect = screen.getByRole('combobox', { name: /rol/i })
|
||||||
|
await userEvent.selectOptions(rolSelect, 'admin')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const filtered = requests.find((u) => u.includes('rol=admin'))
|
||||||
|
expect(filtered).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('typing in search input triggers request with search param (debounced)', async () => {
|
||||||
|
const requests: string[] = []
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
requests.push(request.url)
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() => expect(requests.length).toBeGreaterThan(0))
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/buscar/i)
|
||||||
|
// Use fireEvent to type quickly without delay — then wait for debounce naturally
|
||||||
|
await userEvent.type(searchInput, 'juan')
|
||||||
|
|
||||||
|
// After debounce (300ms + render cycles), should include search param
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
const searched = requests.find((u) => u.includes('search='))
|
||||||
|
expect(searched).toBeTruthy()
|
||||||
|
},
|
||||||
|
{ timeout: 3000 },
|
||||||
|
)
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
it('click row navigates to edit page', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: makeItems(2), page: 1, pageSize: 20, total: 2 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
// Wait for data to load
|
||||||
|
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Click on the username cell which is inside the row
|
||||||
|
const usernameCell = screen.getByText('user1')
|
||||||
|
await userEvent.click(usernameCell)
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/usuarios/1/editar')
|
||||||
|
})
|
||||||
|
})
|
||||||
70
src/web/src/tests/features/users/listUsers.test.ts
Normal file
70
src/web/src/tests/features/users/listUsers.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { listUsers } from '../../../features/users/api/listUsers'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockPage1 = {
|
||||||
|
items: [
|
||||||
|
{ id: 1, username: 'admin', nombre: 'Admin', apellido: 'Sistema', email: null, rol: 'admin', activo: true, ultimoLogin: null },
|
||||||
|
{ id: 2, username: 'cajero1', nombre: 'Juan', apellido: 'Pérez', email: 'j@x.com', rol: 'cajero', activo: true, ultimoLogin: '2026-04-10T10:00:00Z' },
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
describe('listUsers api client', () => {
|
||||||
|
it('calls GET /api/v1/users and returns PagedResult', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockPage1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await listUsers({})
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
expect(result.page).toBe(1)
|
||||||
|
expect(result.pageSize).toBe(20)
|
||||||
|
expect(result.total).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes query params: page, pageSize, rol, activo, search', async () => {
|
||||||
|
let capturedUrl: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await listUsers({ page: 2, pageSize: 10, rol: 'cajero', activo: false, search: 'juan' })
|
||||||
|
|
||||||
|
expect(capturedUrl).toContain('page=2')
|
||||||
|
expect(capturedUrl).toContain('pageSize=10')
|
||||||
|
expect(capturedUrl).toContain('rol=cajero')
|
||||||
|
expect(capturedUrl).toContain('activo=false')
|
||||||
|
expect(capturedUrl).toContain('search=juan')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits undefined params from querystring', async () => {
|
||||||
|
let capturedUrl: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await listUsers({ page: 1 })
|
||||||
|
|
||||||
|
expect(capturedUrl).not.toContain('rol=')
|
||||||
|
expect(capturedUrl).not.toContain('activo=')
|
||||||
|
expect(capturedUrl).not.toContain('search=')
|
||||||
|
})
|
||||||
|
})
|
||||||
67
src/web/src/tests/features/users/useUsersList.test.ts
Normal file
67
src/web/src/tests/features/users/useUsersList.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { renderHook, waitFor } 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 { useUsersList } from '../../../features/users/hooks/useUsersList'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useUsersList', () => {
|
||||||
|
it('fetches page 1 by default', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: [{ id: 1, username: 'admin', nombre: 'Admin', apellido: 'S', email: null, rol: 'admin', activo: true, ultimoLogin: null }], page: 1, pageSize: 20, total: 1 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUsersList({}), { wrapper: createWrapper() })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(result.current.data?.page).toBe(1)
|
||||||
|
expect(result.current.data?.items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes rol filter in query string', async () => {
|
||||||
|
let capturedUrl: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUsersList({ rol: 'admin' }), { wrapper: createWrapper() })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(capturedUrl).toContain('rol=admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes activo filter', async () => {
|
||||||
|
let capturedUrl: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUsersList({ activo: false }), { wrapper: createWrapper() })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(capturedUrl).toContain('activo=false')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user