diff --git a/src/web/src/api/audit.ts b/src/web/src/api/audit.ts new file mode 100644 index 0000000..dcf8be5 --- /dev/null +++ b/src/web/src/api/audit.ts @@ -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 { + 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( + '/api/v1/audit/events', + { params }, + ) + return response.data +} diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 0ca5055..7263153 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -9,6 +9,7 @@ import { Users, ShieldCheck, KeyRound, + FileClock, PanelLeftClose, PanelLeftOpen, } from 'lucide-react' @@ -23,6 +24,8 @@ interface NavItem { href: string icon: React.ElementType disabled?: boolean + /** Si se define, el item solo se muestra si el user tiene este permiso. */ + requiredPermission?: string } const navItems: NavItem[] = [ @@ -38,6 +41,12 @@ const adminItems: NavItem[] = [ { label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus }, { label: 'Roles', href: '/admin/roles', icon: ShieldCheck }, { label: 'Permisos', href: '/admin/permisos', icon: KeyRound }, + { + label: 'Auditoría', + href: '/admin/audit', + icon: FileClock, + requiredPermission: 'administracion:auditoria:ver', + }, ] interface SidebarNavProps { @@ -120,14 +129,20 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) { {isAdmin && ( <> Administración - {adminItems.map((item) => ( - - ))} + {adminItems + .filter( + (item) => + !item.requiredPermission || + user?.permisos.includes(item.requiredPermission), + ) + .map((item) => ( + + ))} )} diff --git a/src/web/src/features/admin/audit/useAuditEvents.ts b/src/web/src/features/admin/audit/useAuditEvents.ts new file mode 100644 index 0000000..4692fea --- /dev/null +++ b/src/web/src/features/admin/audit/useAuditEvents.ts @@ -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({ + queryKey: auditEventsQueryKey(filter), + queryFn: () => listAuditEvents(filter), + staleTime: 15_000, + }) +} diff --git a/src/web/src/pages/admin/audit/AuditFilters.tsx b/src/web/src/pages/admin/audit/AuditFilters.tsx new file mode 100644 index 0000000..f55cc9f --- /dev/null +++ b/src/web/src/pages/admin/audit/AuditFilters.tsx @@ -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(initialValue) + + function handleSubmit(e: FormEvent) { + e.preventDefault() + onApply(value) + } + + function handleReset() { + setValue(EMPTY_FILTERS) + onReset() + } + + return ( +
+
+
+ + setValue((v) => ({ ...v, actor: e.target.value }))} + placeholder="Ej: 42" + /> +
+ +
+ + + setValue((v) => ({ ...v, targetType: e.target.value })) + } + placeholder="Ej: User, Role, Permission" + /> +
+ +
+ + + setValue((v) => ({ ...v, targetId: e.target.value })) + } + placeholder="Ej: 123 o un GUID" + /> +
+ +
+ + setValue((v) => ({ ...v, from: e.target.value }))} + /> +
+ +
+ + setValue((v) => ({ ...v, to: e.target.value }))} + /> +
+
+ +
+ + +
+
+ ) +} + +/** + * 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 +} diff --git a/src/web/src/pages/admin/audit/AuditPage.tsx b/src/web/src/pages/admin/audit/AuditPage.tsx new file mode 100644 index 0000000..b16e960 --- /dev/null +++ b/src/web/src/pages/admin/audit/AuditPage.tsx @@ -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 { + 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(EMPTY_FILTERS) + // Cursor actual (undefined = primera página). + const [cursor, setCursor] = useState(undefined) + // Acumulador de items entre páginas ("cargar más"). + const [accumulated, setAccumulated] = useState([]) + + const apiFilter = useMemo(() => { + 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[]>( + () => [ + { + accessorKey: 'occurredAt', + header: 'Fecha', + cell: ({ row }) => ( + + {formatOccurredAt(row.original.occurredAt)} + + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'actorUsername', + header: 'Usuario', + cell: ({ row }) => { + const u = row.original.actorUsername + if (!u) { + return ( + + sistema + + ) + } + return {u} + }, + meta: { priority: 'high' }, + }, + { + accessorKey: 'action', + header: 'Acción', + cell: ({ row }) => ( + + {row.original.action} + + ), + meta: { priority: 'high' }, + }, + { + id: 'target', + header: 'Entidad', + cell: ({ row }) => ( +
+ + {row.original.targetType} + + + {row.original.targetId} + +
+ ), + meta: { priority: 'medium' }, + }, + { + accessorKey: 'ipAddress', + header: 'IP', + cell: ({ row }) => ( + + {row.original.ipAddress ?? '—'} + + ), + meta: { priority: 'low' }, + }, + { + accessorKey: 'correlationId', + header: 'Correlation', + cell: ({ row }) => { + const cid = row.original.correlationId + if (!cid) return + const short = cid.length > 10 ? `${cid.slice(0, 8)}…` : cid + return ( +
+ + {short} + + + + + + Copiar correlation ID + +
+ ) + }, + meta: { priority: 'low' }, + }, + ], + [], + ) + + const hasMore = Boolean(data?.nextCursor) + + return ( +
+
+
+

+ Auditoría +

+

+ Historial de eventos del sistema. Resultados paginados por cursor. +

+
+
+ + + + {isError ? ( +
+ No se pudieron cargar los eventos de auditoría. Intentá de nuevo. +
+ ) : ( + String(row.id)} + isLoading={isLoading && accumulated.length === 0} + emptyMessage="Sin resultados — no se encontraron eventos con los filtros seleccionados." + /> + )} + +
+ + {accumulated.length > 0 + ? `${accumulated.length} evento${accumulated.length !== 1 ? 's' : ''} cargado${accumulated.length !== 1 ? 's' : ''}` + : ''} + + +
+ + {/* + TODOs para ADM-004: + - Drill-down del evento (modal con metadata JSON formatted) + - Export CSV de los resultados filtrados + - Timeline visualization por entidad + */} +
+ ) +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 9feac6f..2a83906 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -12,6 +12,7 @@ import { RolesPage } from './features/roles/pages/RolesPage' import { NewRolPage } from './features/roles/pages/NewRolPage' import { EditRolPage } from './features/roles/pages/EditRolPage' import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage' +import { AuditPage } from './pages/admin/audit/AuditPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -154,6 +155,15 @@ export function AppRoutes() { } /> + + + + } + /> + } /> ) diff --git a/src/web/src/tests/features/admin/audit/AuditPage.test.tsx b/src/web/src/tests/features/admin/audit/AuditPage.test.tsx new file mode 100644 index 0000000..f242ba2 --- /dev/null +++ b/src/web/src/tests/features/admin/audit/AuditPage.test.tsx @@ -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 = {}) { + 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( + + + + + + + , + ) +} + +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('') + }) +}) diff --git a/src/web/src/tests/features/admin/audit/useAuditEvents.test.ts b/src/web/src/tests/features/admin/audit/useAuditEvents.test.ts new file mode 100644 index 0000000..3ff6f80 --- /dev/null +++ b/src/web/src/tests/features/admin/audit/useAuditEvents.test.ts @@ -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=') + }) +})