import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AlertRulesPage } from '../../src/AlertRulesPage'; afterEach(() => { cleanup(); vi.unstubAllGlobals(); window.history.replaceState({}, '', '/alerts'); }); describe('begeleide alertregelbewerking', () => { it('houdt de werkruimte gesloten wanneer het regelscontract toegang weigert', async () => { vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => { if (String(input).includes('/alert-rules?')) return Promise.resolve(new Response('{}', { status: 403 })); return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); })); render(); expect(await screen.findByRole('heading', { name: 'Geen toegang tot alertregels' })).toBeVisible(); expect(screen.queryByRole('navigation', { name: 'Werkruimte voor meldingen' })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Actieve en recente meldingen' })).not.toBeInTheDocument(); }); it('laadt de metriccatalogus en blokkeert een ongeldige regel', async () => { const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [ { semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' }, ] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); if (url.includes('/alert-silences') || url.includes('/maintenance-windows') || url.includes('/alerts?')) return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); }); vi.stubGlobal('fetch', fetchMock); const user = userEvent.setup(); render(); await user.click((await screen.findByText('Alertregels')).closest('button')!); const save = await screen.findByRole('button', { name: 'Regel opslaan' }); expect(save).toBeDisabled(); const metric = screen.getByRole('combobox', { name: /Meting/ }) as HTMLSelectElement; expect(metric).toHaveTextContent('CPU-gebruik van de host (%)'); expect(screen.queryByText('host.cpu.utilization')).not.toBeInTheDocument(); await user.type(document.querySelector('#alert-rule-name')!, 'Hoge hostbelasting'); await user.selectOptions(metric, 'host.cpu.utilization'); expect(save).toBeEnabled(); const technical = screen.getByText('Technische regelgegevens').closest('details'); expect(technical).not.toHaveAttribute('open'); expect(screen.getByLabelText('Host niet bereikbaar')).not.toBeChecked(); await user.click(screen.getByLabelText('Host niet bereikbaar')); expect(screen.getByLabelText('Host niet bereikbaar')).toBeChecked(); await user.click(screen.getByText('Stiltes en onderhoud').closest('button')!); expect(document.querySelector('#silence-matcher')).toHaveTextContent('Kritiek'); expect(document.querySelector('#maintenance-selector')).toHaveTextContent('Host'); expect(window.location.search).toBe('?section=controls'); }); it('laat bestaande niet-metrische regels met typeafhankelijke validatie bewerken', async () => { const eventRule = { id: '81111111-1111-4111-8111-111111111111', schemaVersion: 1, name: 'Container bevindt zich in een herstartlus', enabled: true, severity: 'degraded', scope: {}, condition: { inputType: 'event', operator: '>=', threshold: 3, aggregation: 'count', windowSeconds: 900 }, evaluationIntervalSeconds: 30, pendingSeconds: 0, resolveSeconds: 900, cooldownSeconds: 0, unknownBehavior: 'become-unknown', groupBy: [], suppressWhen: ['host.unreachable'], message: { titleKey: 'alerts.restart.title', bodyKey: 'alerts.restart.body' }, revision: 1, currentVersion: 1, }; vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); if (url.includes('/alert-rules?')) return Promise.resolve(new Response(JSON.stringify({ items: [eventRule] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); })); render(); await userEvent.setup().click((await screen.findByText('Alertregels')).closest('button')!); expect(await screen.findByDisplayValue('Container bevindt zich in een herstartlus')).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: /Signaalbron/ })).toHaveValue('event'); expect(screen.queryByRole('combobox', { name: /Meting/ })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Regel opslaan' })).toBeEnabled(); }); it('zet kritieke actieve meldingen vooraan en bewaart confirmatie, revisie en idempotentie', async () => { const alerts = Array.from({ length: 25 }, (_, index) => ({ id: `alert-${String(index + 1).padStart(2, '0')}`, state: index === 1 ? 'acknowledged' : 'firing', retainedState: 'firing', ruleName: `Melding ${String(index + 1).padStart(2, '0')}`, severity: index % 5 === 0 ? 'critical' : 'attention', entityName: 'Tower', reason: 'threshold_exceeded', revision: index + 1, updatedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(), })); const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); if (url.includes('/alert-rules?')) return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); if (url.includes('/alerts?')) return Promise.resolve(new Response(JSON.stringify({ items: alerts }), { status: 200, headers: { 'Content-Type': 'application/json' } })); if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } })); return Promise.resolve(new Response(JSON.stringify({ alert: alerts[0] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); }); vi.stubGlobal('fetch', fetchMock); const confirm = vi.fn(() => false); vi.stubGlobal('confirm', confirm); const user = userEvent.setup(); render(); const critical = (await screen.findByText('Kritiek actief')).closest('button')!; expect(screen.getByText('Actief').closest('button')).toHaveAttribute('aria-pressed', 'true'); await waitFor(() => expect(document.querySelectorAll('.alert-operation-list-items li')).toHaveLength(20)); expect(document.querySelector('.alert-operation-list-items li')).toHaveTextContent('Kritiek'); await user.click(critical); await waitFor(() => expect(document.querySelectorAll('.alert-operation-list-items li')).toHaveLength(5)); const acknowledge = screen.getAllByRole('button', { name: 'Erkennen' })[0]; await user.click(acknowledge); expect(confirm).toHaveBeenCalledWith('Deze melding erkennen? De evaluatie en geschiedenis blijven behouden.'); expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false); confirm.mockReturnValue(true); await user.click(acknowledge); const operation = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST'); expect(operation?.[1]?.headers).toMatchObject({ 'If-Match': '1' }); expect((operation?.[1]?.headers as Record)['Idempotency-Key']).toBeTruthy(); }); });