feat(web): /admin/audit page + filtros (UDT-010 B12)

Read-only audit timeline per design #D-9. Delegated to sub-agent, completed
before rate limit cutoff; verified with vitest 161/161 passing.

New files:
- src/web/src/api/audit.ts — axios client: listAuditEvents(filter)
- src/web/src/features/admin/audit/useAuditEvents.ts — TanStack Query hook
- src/web/src/pages/admin/audit/AuditPage.tsx — DataTable + 4 filters + cursor
  pagination 'Cargar más' button. Columns: OccurredAt (local time formatted),
  ActorUsername, Action (badge), TargetType + TargetId, IpAddress, CorrelationId
  (copy button with toast).
- src/web/src/pages/admin/audit/AuditFilters.tsx — 4 filters form.
- src/web/src/tests/features/admin/audit/useAuditEvents.test.ts — hook unit.
- src/web/src/tests/features/admin/audit/AuditPage.test.tsx — component test
  with MSW handler mock.

Modified:
- src/web/src/router.tsx — /admin/audit route, protected by auth + permission
  'administracion:auditoria:ver'.
- src/web/src/components/layout/AppSidebar.tsx — sidebar entry (icon, visible
  only with the required permission, uses existing permission-filtering pattern).

OUT of scope (deferred to ADM-004):
- Row drilldown modal with full metadata JSON formatted.
- CSV export.
- Timeline-per-entity visualization.

Design System v2.4 conventions respected: DataTable component from
@/components/ui/data-table (no raw <table>), tokens only (no hex inline),
density compact, Radix tooltip for copy button, sonner toast on copy.

Vitest run: 29 test files / 161 tests passing. No regressions in existing
frontend tests.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec, design#D-9, tasks#B12}
This commit is contained in:
2026-04-16 17:07:13 -03:00
parent 2bb90118ab
commit b526df2125
8 changed files with 965 additions and 8 deletions

71
src/web/src/api/audit.ts Normal file
View File

@@ -0,0 +1,71 @@
import { axiosClient } from '@/api/axiosClient'
/**
* Auditoría — UDT-010 Batch 12
*
* Cliente Axios para GET /api/v1/audit/events.
* Endpoint cursor-paginated (NO offset). Permiso requerido: administracion:auditoria:ver.
*/
/** Shape del evento devuelto por el backend (B5/B6). */
export interface AuditEventDto {
id: number
/** ISO datetime (UTC) */
occurredAt: string
actorUserId: number | null
actorUsername: string | null
action: string
targetType: string
targetId: string
correlationId: string | null
ipAddress: string | null
/** JSON string serializado (opcional) */
metadata: string | null
}
/** Respuesta cursor-paginated: items + cursor opaco para la siguiente página. */
export interface AuditEventsPage {
items: AuditEventDto[]
nextCursor: string | null
}
/** Filtro de consulta. Todos opcionales; `cursor` pagina; `limit` ≤ 100. */
export interface AuditEventsFilter {
actor?: number
targetType?: string
targetId?: string
/** ISO datetime inclusive desde */
from?: string
/** ISO datetime inclusive hasta */
to?: string
cursor?: string
limit?: number
}
/**
* Llama GET /api/v1/audit/events con los filtros dados.
* Omite params undefined/empty del querystring.
*/
export async function listAuditEvents(
filter: AuditEventsFilter = {},
): Promise<AuditEventsPage> {
const params = new URLSearchParams()
if (filter.actor !== undefined) params.set('actor', String(filter.actor))
if (filter.targetType !== undefined && filter.targetType !== '')
params.set('targetType', filter.targetType)
if (filter.targetId !== undefined && filter.targetId !== '')
params.set('targetId', filter.targetId)
if (filter.from !== undefined && filter.from !== '')
params.set('from', filter.from)
if (filter.to !== undefined && filter.to !== '') params.set('to', filter.to)
if (filter.cursor !== undefined && filter.cursor !== '')
params.set('cursor', filter.cursor)
if (filter.limit !== undefined) params.set('limit', String(filter.limit))
const response = await axiosClient.get<AuditEventsPage>(
'/api/v1/audit/events',
{ params },
)
return response.data
}

View File

@@ -9,6 +9,7 @@ import {
Users, Users,
ShieldCheck, ShieldCheck,
KeyRound, KeyRound,
FileClock,
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
} from 'lucide-react' } from 'lucide-react'
@@ -23,6 +24,8 @@ interface NavItem {
href: string href: string
icon: React.ElementType icon: React.ElementType
disabled?: boolean disabled?: boolean
/** Si se define, el item solo se muestra si el user tiene este permiso. */
requiredPermission?: string
} }
const navItems: NavItem[] = [ const navItems: NavItem[] = [
@@ -38,6 +41,12 @@ const adminItems: NavItem[] = [
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus }, { label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck }, { label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
{ label: 'Permisos', href: '/admin/permisos', icon: KeyRound }, { label: 'Permisos', href: '/admin/permisos', icon: KeyRound },
{
label: 'Auditoría',
href: '/admin/audit',
icon: FileClock,
requiredPermission: 'administracion:auditoria:ver',
},
] ]
interface SidebarNavProps { interface SidebarNavProps {
@@ -120,14 +129,20 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
{isAdmin && ( {isAdmin && (
<> <>
<SectionLabel collapsed={collapsed}>Administración</SectionLabel> <SectionLabel collapsed={collapsed}>Administración</SectionLabel>
{adminItems.map((item) => ( {adminItems
<NavRow .filter(
key={item.href} (item) =>
item={item} !item.requiredPermission ||
collapsed={collapsed} user?.permisos.includes(item.requiredPermission),
active={isItemActive(item)} )
/> .map((item) => (
))} <NavRow
key={item.href}
item={item}
collapsed={collapsed}
active={isItemActive(item)}
/>
))}
</> </>
)} )}
</nav> </nav>

View File

@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query'
import {
listAuditEvents,
type AuditEventsFilter,
type AuditEventsPage,
} from '@/api/audit'
export const auditEventsQueryKey = (filter: AuditEventsFilter) =>
['audit', 'events', filter] as const
/**
* Hook TanStack Query para `listAuditEvents`.
*
* Cursor pagination: el caller pasa `cursor` (o `undefined` para la primera
* página) dentro de `filter`. Cada "página" es una query independiente —
* NO usamos `useInfiniteQuery` porque el UI usa un botón "Cargar más" que
* simplemente re-consulta con el último `nextCursor`.
*/
export function useAuditEvents(filter: AuditEventsFilter) {
return useQuery<AuditEventsPage>({
queryKey: auditEventsQueryKey(filter),
queryFn: () => listAuditEvents(filter),
staleTime: 15_000,
})
}

View File

@@ -0,0 +1,160 @@
import { useState, type FormEvent } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
/** Filtros crudos del form (todos strings para binding directo del input). */
export interface AuditFiltersValue {
actor: string
targetType: string
targetId: string
from: string
to: string
}
export const EMPTY_FILTERS: AuditFiltersValue = {
actor: '',
targetType: '',
targetId: '',
from: '',
to: '',
}
interface AuditFiltersProps {
/** Valor inicial (útil cuando el padre mantiene el estado). */
initialValue?: AuditFiltersValue
onApply: (value: AuditFiltersValue) => void
onReset: () => void
}
/**
* 4 filtros + 2 fechas (from/to). Submit explícito vía botón "Aplicar".
*
* NO hace debounce — el usuario aprieta "Aplicar" o "Limpiar".
* Eso evita pedidos intermedios cuando se escribe un GUID largo en targetId.
*/
export function AuditFilters({
initialValue = EMPTY_FILTERS,
onApply,
onReset,
}: AuditFiltersProps) {
const [value, setValue] = useState<AuditFiltersValue>(initialValue)
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
onApply(value)
}
function handleReset() {
setValue(EMPTY_FILTERS)
onReset()
}
return (
<form
onSubmit={handleSubmit}
className="surface p-4 space-y-4"
aria-label="Filtros de auditoría"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label htmlFor="audit-actor">Usuario (ID)</Label>
<Input
id="audit-actor"
type="number"
inputMode="numeric"
min={1}
value={value.actor}
onChange={(e) => setValue((v) => ({ ...v, actor: e.target.value }))}
placeholder="Ej: 42"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-target-type">Tipo de entidad</Label>
<Input
id="audit-target-type"
type="text"
value={value.targetType}
onChange={(e) =>
setValue((v) => ({ ...v, targetType: e.target.value }))
}
placeholder="Ej: User, Role, Permission"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-target-id">ID de entidad</Label>
<Input
id="audit-target-id"
type="text"
value={value.targetId}
onChange={(e) =>
setValue((v) => ({ ...v, targetId: e.target.value }))
}
placeholder="Ej: 123 o un GUID"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-from">Desde</Label>
<Input
id="audit-from"
type="datetime-local"
value={value.from}
onChange={(e) => setValue((v) => ({ ...v, from: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-to">Hasta</Label>
<Input
id="audit-to"
type="datetime-local"
value={value.to}
onChange={(e) => setValue((v) => ({ ...v, to: e.target.value }))}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="ghost" onClick={handleReset}>
Limpiar
</Button>
<Button type="submit">Aplicar filtros</Button>
</div>
</form>
)
}
/**
* Convierte los valores del form (strings) al shape `AuditEventsFilter`
* que espera el cliente de API.
*
* - `actor` vacío o NaN → omitido
* - `from`/`to` vienen del `datetime-local` (local time, sin timezone).
* Los convertimos a ISO UTC vía `new Date(...).toISOString()`.
* - Strings vacíos → omitidos.
*/
export function toApiFilter(
value: AuditFiltersValue,
): import('@/api/audit').AuditEventsFilter {
const out: import('@/api/audit').AuditEventsFilter = {}
if (value.actor.trim() !== '') {
const n = Number(value.actor)
if (Number.isFinite(n) && n > 0) out.actor = Math.floor(n)
}
if (value.targetType.trim() !== '') out.targetType = value.targetType.trim()
if (value.targetId.trim() !== '') out.targetId = value.targetId.trim()
if (value.from.trim() !== '') {
const d = new Date(value.from)
if (!Number.isNaN(d.getTime())) out.from = d.toISOString()
}
if (value.to.trim() !== '') {
const d = new Date(value.to)
if (!Number.isNaN(d.getTime())) out.to = d.toISOString()
}
return out
}

View File

@@ -0,0 +1,285 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import { Copy } from 'lucide-react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DataTable } from '@/components/ui/data-table'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import type { AuditEventDto, AuditEventsFilter } from '@/api/audit'
import { useAuditEvents } from '@/features/admin/audit/useAuditEvents'
import {
AuditFilters,
EMPTY_FILTERS,
toApiFilter,
type AuditFiltersValue,
} from './AuditFilters'
/** Formatea un ISO datetime a hora local AR (dd/mm/yyyy HH:mm:ss). */
function formatOccurredAt(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('es-AR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
/** Copia texto al clipboard con fallback + toast. */
async function copyToClipboard(text: string, label: string): Promise<void> {
try {
if (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === 'function'
) {
await navigator.clipboard.writeText(text)
} else {
// Fallback jsdom / navegadores viejos
const ta = document.createElement('textarea')
ta.value = text
ta.setAttribute('readonly', '')
ta.style.position = 'absolute'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
toast.success(`${label} copiado al portapapeles`)
} catch {
toast.error(`No se pudo copiar ${label.toLowerCase()}`)
}
}
export function AuditPage() {
// Filtros confirmados (los que se mandan a la API). Se actualizan al Aplicar.
const [filters, setFilters] = useState<AuditFiltersValue>(EMPTY_FILTERS)
// Cursor actual (undefined = primera página).
const [cursor, setCursor] = useState<string | undefined>(undefined)
// Acumulador de items entre páginas ("cargar más").
const [accumulated, setAccumulated] = useState<AuditEventDto[]>([])
const apiFilter = useMemo<AuditEventsFilter>(() => {
const f = toApiFilter(filters)
return cursor ? { ...f, cursor } : f
}, [filters, cursor])
const { data, isLoading, isFetching, isError } = useAuditEvents(apiFilter)
// Acumular items cuando llegan. Si es la primera página (cursor=undefined)
// reseteamos; si hay cursor, appendeamos.
useEffect(() => {
if (!data) return
if (cursor === undefined) {
setAccumulated(data.items)
} else {
setAccumulated((prev) => {
// Evitar dobles appends en StrictMode: si el último batch ya incluye
// este primer id, asumimos que ya fue appendeado.
const firstNew = data.items[0]
if (
firstNew &&
prev.length > 0 &&
prev[prev.length - 1]?.id === firstNew.id
) {
return prev
}
return [...prev, ...data.items]
})
}
}, [data, cursor])
const handleApply = useCallback((value: AuditFiltersValue) => {
setFilters(value)
setCursor(undefined)
setAccumulated([])
}, [])
const handleReset = useCallback(() => {
setFilters(EMPTY_FILTERS)
setCursor(undefined)
setAccumulated([])
}, [])
const handleLoadMore = useCallback(() => {
if (data?.nextCursor) {
setCursor(data.nextCursor)
}
}, [data])
const columns = useMemo<ColumnDef<AuditEventDto>[]>(
() => [
{
accessorKey: 'occurredAt',
header: 'Fecha',
cell: ({ row }) => (
<span className="font-mono text-xs text-foreground">
{formatOccurredAt(row.original.occurredAt)}
</span>
),
meta: { priority: 'high' },
},
{
accessorKey: 'actorUsername',
header: 'Usuario',
cell: ({ row }) => {
const u = row.original.actorUsername
if (!u) {
return (
<span className="text-muted-foreground italic">
sistema
</span>
)
}
return <span className="font-mono text-xs">{u}</span>
},
meta: { priority: 'high' },
},
{
accessorKey: 'action',
header: 'Acción',
cell: ({ row }) => (
<Badge variant="secondary" className="font-mono text-[11px]">
{row.original.action}
</Badge>
),
meta: { priority: 'high' },
},
{
id: 'target',
header: 'Entidad',
cell: ({ row }) => (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">
{row.original.targetType}
</span>
<span className="font-mono text-[11px] text-muted-foreground break-all">
{row.original.targetId}
</span>
</div>
),
meta: { priority: 'medium' },
},
{
accessorKey: 'ipAddress',
header: 'IP',
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.ipAddress ?? '—'}
</span>
),
meta: { priority: 'low' },
},
{
accessorKey: 'correlationId',
header: 'Correlation',
cell: ({ row }) => {
const cid = row.original.correlationId
if (!cid) return <span className="text-muted-foreground"></span>
const short = cid.length > 10 ? `${cid.slice(0, 8)}` : cid
return (
<div className="flex items-center gap-1">
<span
className="font-mono text-[11px] text-muted-foreground"
title={cid}
>
{short}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void copyToClipboard(cid, 'Correlation ID')
}}
aria-label="Copiar correlation ID"
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Copy className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent>Copiar correlation ID</TooltipContent>
</Tooltip>
</div>
)
},
meta: { priority: 'low' },
},
],
[],
)
const hasMore = Boolean(data?.nextCursor)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-xl font-semibold text-foreground">
Auditoría
</h1>
<p className="text-sm text-muted-foreground">
Historial de eventos del sistema. Resultados paginados por cursor.
</p>
</div>
</div>
<AuditFilters
initialValue={filters}
onApply={handleApply}
onReset={handleReset}
/>
{isError ? (
<div
role="alert"
className="surface p-4 text-sm text-destructive"
>
No se pudieron cargar los eventos de auditoría. Intentá de nuevo.
</div>
) : (
<DataTable
columns={columns}
data={accumulated}
getRowId={(row) => String(row.id)}
isLoading={isLoading && accumulated.length === 0}
emptyMessage="Sin resultados — no se encontraron eventos con los filtros seleccionados."
/>
)}
<div className="flex items-center justify-between pt-2">
<span className="text-sm text-muted-foreground">
{accumulated.length > 0
? `${accumulated.length} evento${accumulated.length !== 1 ? 's' : ''} cargado${accumulated.length !== 1 ? 's' : ''}`
: ''}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasMore || isFetching}
onClick={handleLoadMore}
aria-label="Cargar más eventos"
>
{isFetching && cursor !== undefined ? 'Cargando…' : 'Cargar más'}
</Button>
</div>
{/*
TODOs para ADM-004:
- Drill-down del evento (modal con metadata JSON formatted)
- Export CSV de los resultados filtrados
- Timeline visualization por entidad
*/}
</div>
)
}

View File

@@ -12,6 +12,7 @@ import { RolesPage } from './features/roles/pages/RolesPage'
import { NewRolPage } from './features/roles/pages/NewRolPage' import { NewRolPage } from './features/roles/pages/NewRolPage'
import { EditRolPage } from './features/roles/pages/EditRolPage' import { EditRolPage } from './features/roles/pages/EditRolPage'
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage' import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
import { AuditPage } from './pages/admin/audit/AuditPage'
import { HomePage } from './pages/HomePage' import { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout' import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout' import { ProtectedLayout } from './layouts/ProtectedLayout'
@@ -154,6 +155,15 @@ export function AppRoutes() {
} }
/> />
<Route
path="/admin/audit"
element={
<ProtectedPage requiredPermissions={['administracion:auditoria:ver']}>
<AuditPage />
</ProtectedPage>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
) )

View File

@@ -0,0 +1,254 @@
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 { TooltipProvider } from '../../../../components/ui/tooltip'
import { AuditPage } from '../../../../pages/admin/audit/AuditPage'
const API_URL = 'http://localhost:5000'
// Sonner toast is mocked so we can assert it was called without rendering.
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
function makeEvent(id: number, overrides: Record<string, unknown> = {}) {
return {
id,
occurredAt: `2026-04-${String(10 + (id % 20)).padStart(2, '0')}T10:00:00Z`,
actorUserId: 1,
actorUsername: `user${id}`,
action: 'user.created',
targetType: 'User',
targetId: String(100 + id),
correlationId: `corr-${id}`,
ipAddress: '10.0.0.1',
metadata: null,
...overrides,
}
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
vi.clearAllMocks()
})
afterAll(() => server.close())
function renderPage() {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(
<QueryClientProvider client={qc}>
<TooltipProvider>
<MemoryRouter initialEntries={['/admin/audit']}>
<AuditPage />
</MemoryRouter>
</TooltipProvider>
</QueryClientProvider>,
)
}
describe('AuditPage', () => {
it('renders the table with rows returned by the API', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({
items: [makeEvent(1), makeEvent(2), makeEvent(3)],
nextCursor: null,
}),
),
)
renderPage()
await waitFor(() =>
expect(screen.getByText('user1')).toBeInTheDocument(),
)
expect(screen.getByText('user2')).toBeInTheDocument()
expect(screen.getByText('user3')).toBeInTheDocument()
// Action badge present
const badges = screen.getAllByText('user.created')
expect(badges.length).toBe(3)
})
it('shows empty state when no items are returned', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({ items: [], nextCursor: null }),
),
)
renderPage()
await waitFor(() =>
expect(
screen.getByText(/sin resultados|no se encontraron eventos/i),
).toBeInTheDocument(),
)
})
it('applies filters on submit — sends actor + targetType as query params', async () => {
const requests: string[] = []
server.use(
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
requests.push(request.url)
return HttpResponse.json({ items: [], nextCursor: null })
}),
)
const u = userEvent.setup()
renderPage()
await waitFor(() => expect(requests.length).toBeGreaterThan(0))
const actorInput = screen.getByLabelText(/usuario \(id\)/i)
await u.type(actorInput, '42')
const targetTypeInput = screen.getByLabelText(/tipo de entidad/i)
await u.type(targetTypeInput, 'User')
const applyBtn = screen.getByRole('button', { name: /aplicar filtros/i })
await u.click(applyBtn)
await waitFor(() => {
const withFilters = requests.find(
(url) => url.includes('actor=42') && url.includes('targetType=User'),
)
expect(withFilters).toBeTruthy()
})
})
it('"Cargar más" is disabled when nextCursor is null', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({
items: [makeEvent(1)],
nextCursor: null,
}),
),
)
renderPage()
await waitFor(() =>
expect(screen.getByText('user1')).toBeInTheDocument(),
)
const loadMoreBtn = screen.getByRole('button', {
name: /cargar más/i,
})
expect(loadMoreBtn).toBeDisabled()
})
it('"Cargar más" fetches next page with cursor and appends rows', async () => {
const requests: string[] = []
server.use(
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
requests.push(request.url)
const url = new URL(request.url)
const cursor = url.searchParams.get('cursor')
if (cursor === 'cursor-page-2') {
return HttpResponse.json({
items: [makeEvent(10), makeEvent(11)],
nextCursor: null,
})
}
return HttpResponse.json({
items: [makeEvent(1), makeEvent(2)],
nextCursor: 'cursor-page-2',
})
}),
)
const u = userEvent.setup()
renderPage()
await waitFor(() =>
expect(screen.getByText('user1')).toBeInTheDocument(),
)
expect(screen.getByText('user2')).toBeInTheDocument()
const loadMoreBtn = screen.getByRole('button', {
name: /cargar más/i,
})
expect(loadMoreBtn).not.toBeDisabled()
await u.click(loadMoreBtn)
// Second request with cursor param
await waitFor(() => {
const paged = requests.find((url) =>
url.includes('cursor=cursor-page-2'),
)
expect(paged).toBeTruthy()
})
// Appended rows appear alongside originals
await waitFor(() =>
expect(screen.getByText('user10')).toBeInTheDocument(),
)
expect(screen.getByText('user11')).toBeInTheDocument()
// Original rows still visible (append, not replace)
expect(screen.getByText('user1')).toBeInTheDocument()
})
it('shows "sistema" placeholder when actorUsername is null', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({
items: [makeEvent(1, { actorUsername: null, actorUserId: null })],
nextCursor: null,
}),
),
)
renderPage()
await waitFor(() =>
expect(screen.getByText('sistema')).toBeInTheDocument(),
)
})
it('"Limpiar" clears the form inputs', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({ items: [], nextCursor: null }),
),
)
const u = userEvent.setup()
renderPage()
const actorInput = screen.getByLabelText(
/usuario \(id\)/i,
) as HTMLInputElement
await u.type(actorInput, '42')
expect(actorInput.value).toBe('42')
await u.click(screen.getByRole('button', { name: /limpiar/i }))
// Form field cleared after reset
expect(actorInput.value).toBe('')
})
})

View File

@@ -0,0 +1,137 @@
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 { useAuditEvents } from '../../../../features/admin/audit/useAuditEvents'
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)
}
function makeEvent(id: number) {
return {
id,
occurredAt: `2026-04-${String(10 + (id % 20)).padStart(2, '0')}T10:00:00Z`,
actorUserId: 1,
actorUsername: 'admin',
action: 'user.created',
targetType: 'User',
targetId: String(100 + id),
correlationId: `corr-${id}`,
ipAddress: '10.0.0.1',
metadata: null,
}
}
describe('useAuditEvents', () => {
it('fetches the first page (no cursor) and returns items + nextCursor', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({
items: [makeEvent(1), makeEvent(2)],
nextCursor: 'cursor-page-2',
}),
),
)
const { result } = renderHook(() => useAuditEvents({}), {
wrapper: createWrapper(),
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.items).toHaveLength(2)
expect(result.current.data?.nextCursor).toBe('cursor-page-2')
})
it('forwards filters as query params (actor, targetType, targetId, from, to)', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({ items: [], nextCursor: null })
}),
)
const { result } = renderHook(
() =>
useAuditEvents({
actor: 42,
targetType: 'User',
targetId: 'abc-123',
from: '2026-04-01T00:00:00Z',
to: '2026-04-16T23:59:59Z',
}),
{ wrapper: createWrapper() },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(capturedUrl).toContain('actor=42')
expect(capturedUrl).toContain('targetType=User')
expect(capturedUrl).toContain('targetId=abc-123')
expect(capturedUrl).toContain('from=2026-04-01')
expect(capturedUrl).toContain('to=2026-04-16')
})
it('forwards cursor param for successive pages', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({
items: [makeEvent(3)],
nextCursor: null,
})
}),
)
const { result } = renderHook(
() => useAuditEvents({ cursor: 'cursor-page-2' }),
{ wrapper: createWrapper() },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(capturedUrl).toContain('cursor=cursor-page-2')
})
it('omits undefined/empty filters from the querystring', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({ items: [], nextCursor: null })
}),
)
const { result } = renderHook(
() => useAuditEvents({ actor: 1, targetType: '' }),
{ wrapper: createWrapper() },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(capturedUrl).toContain('actor=1')
expect(capturedUrl).not.toContain('targetType=')
expect(capturedUrl).not.toContain('from=')
expect(capturedUrl).not.toContain('to=')
expect(capturedUrl).not.toContain('cursor=')
})
})