This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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(<AlertRulesPage />);
|
||||
|
||||
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(<AlertRulesPage />);
|
||||
|
||||
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<HTMLInputElement>('#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(<AlertRulesPage />);
|
||||
|
||||
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(<AlertRulesPage />);
|
||||
|
||||
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<string, string>)['Idempotency-Key']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from '../../src/App';
|
||||
import { resetSystemStatusForTests } from '../../src/systemStatus';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetSystemStatusForTests();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
function json(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
const healthySource = { state: 'healthy', freshness: 'fresh' };
|
||||
|
||||
describe('Stitch command overview', () => {
|
||||
it('shows real source values while failed sources remain explicitly unavailable', async () => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({
|
||||
version: '1', generatedAt: now, overallState: 'healthy', components: [
|
||||
{ id: 'database', state: 'healthy', reason: 'ok' },
|
||||
{ id: 'prometheus', state: 'healthy', reason: 'ok' },
|
||||
], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'prometheus', state: 'degraded', reason: 'source_stale', ageSeconds: 420 }],
|
||||
});
|
||||
if (url === '/api/v1/host') return json({
|
||||
identity: { name: 'tower-lab' },
|
||||
cpu: { totalPercent: 37.5, perCore: [25, 50] },
|
||||
memory: { utilizationPercent: 62.25 },
|
||||
source: healthySource,
|
||||
});
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [{ id: 'running', state: 'RUNNING', health: 'healthy' }, { id: 'stopped', state: 'stopped', health: 'unknown' }], total: 2 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'svc-1', name: 'API', state: 'up' }], total: 1 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ error: 'source unavailable' }, 503);
|
||||
return json({ error: 'unexpected request' }, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect((await screen.findAllByText('37,5%'))[0]).toBeVisible();
|
||||
expect(document.querySelector('.instrument-band')).toBeInTheDocument();
|
||||
expect(document.querySelector('.data-plane')).toBeInTheDocument();
|
||||
expect(document.querySelector('.focus-panel')).toBeInTheDocument();
|
||||
expect(document.querySelector('.context-inspector')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
|
||||
const unavailableStorage = screen.getByRole('button', { name: /Opslag: Niet beschikbaar/ });
|
||||
expect(unavailableStorage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
|
||||
const staleSource = screen.getByRole('button', { name: /Bronnen: Aandacht.*0\/1/ });
|
||||
expect(staleSource.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'attention');
|
||||
expect(screen.getAllByText('62,3%')[0]).toBeVisible();
|
||||
expect(screen.getByText('tower-lab')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: /Workloads: Kritiek.*1\/2/ })).toBeVisible();
|
||||
expect(screen.queryByText('0 pools')).not.toBeInTheDocument();
|
||||
const storageCard = screen.getByRole('heading', { name: 'Capaciteit en toestand' }).closest('article');
|
||||
expect(storageCard).not.toBeNull();
|
||||
expect(within(storageCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
|
||||
expect(screen.getByText('Opslag: Niet beschikbaar')).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: 'Aandacht vereist' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('suppresses current workload claims when retained container data is stale', async () => {
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
|
||||
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: { state: 'unknown', freshness: 'stale' }, containers: [{ id: 'retained', state: 'running', health: 'healthy' }], total: 1 });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Workloads: Verouderd.*actieve containers: —/ })).toBeVisible();
|
||||
const workloadCard = screen.getByRole('heading', { name: 'Containers' }).closest('article');
|
||||
expect(workloadCard).not.toBeNull();
|
||||
expect(within(workloadCard!).getByText(/laatst bekende waarden worden niet als actueel getoond/i)).toBeVisible();
|
||||
expect(within(workloadCard!).queryByText(/1 van 1 containers zijn actief/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('settles independent resources while a single endpoint is still pending', async () => {
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
|
||||
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'api', name: 'API', state: 'up' }], total: 1 });
|
||||
if (url.startsWith('/api/v1/incidents')) return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
|
||||
});
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Host: Gezond.*30%.*40%/ })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: /Incidenten: Wordt geladen/ })).toBeVisible();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Status nog niet bevestigd' })).toBeVisible();
|
||||
const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
|
||||
expect(incidentCard).not.toBeNull();
|
||||
expect(within(incidentCard!).getByText(/wordt geladen; er wordt nog geen toestand verondersteld/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not present an unavailable incident feed as an empty healthy feed', async () => {
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
|
||||
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ code: 'UNAVAILABLE' }, 503);
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Incidenten: Niet beschikbaar/ })).toBeVisible();
|
||||
const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
|
||||
expect(incidentCard).not.toBeNull();
|
||||
expect(within(incidentCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
|
||||
expect(within(incidentCard!).queryByText('Er zijn geen open incidenten geregistreerd.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('distinguishes forbidden resources from an expired session', async () => {
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ code: 'FORBIDDEN' }, 403);
|
||||
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ code: 'FORBIDDEN' }, 403);
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Bronnen: Geen toegang/ })).toBeVisible();
|
||||
expect(await screen.findByRole('button', { name: /Opslag: Geen toegang/ })).toBeVisible();
|
||||
expect(screen.getByText('Opslag: Geen toegang')).toBeVisible();
|
||||
expect(screen.getAllByText(/account heeft geen toegang tot deze overzichtsbron/).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('link', { name: /Aanmelden/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prioriteert kritieke poolcapaciteit boven gezonde device-health', async () => {
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [{ id: 'ssd', name: 'ssd', state: 'healthy', capacitySeverity: 'critical', utilizationPercent: 98.8 }], total: 1 });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
|
||||
if (url === '/api/v1/host') return json({ source: healthySource });
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('ssd: Kritiek')).toBeVisible();
|
||||
expect(screen.getByText(/kritieke capaciteitsgrens is overschreden/)).toBeVisible();
|
||||
expect(screen.getByText('Kritiek · device-health gezond')).toBeVisible();
|
||||
expect(await screen.findByRole('heading', { name: 'Aandacht vereist' })).toBeVisible();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Opslag: Kritiek/ })).toHaveAttribute('aria-pressed', 'true'));
|
||||
});
|
||||
|
||||
it('retries every overview resource from the shared retry action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const now = new Date().toISOString();
|
||||
let poolCalls = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'ok' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
|
||||
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
|
||||
if (url.startsWith('/api/v1/pools')) {
|
||||
poolCalls += 1;
|
||||
if (poolCalls === 1) return json({ code: 'POOLS_UNAVAILABLE' }, 503);
|
||||
return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
|
||||
}
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByText('Opslag: Niet beschikbaar')).toBeVisible();
|
||||
await user.click(screen.getByRole('button', { name: 'Opnieuw laden' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Opslag: Gezond/ })).toBeVisible();
|
||||
expect(poolCalls).toBe(2);
|
||||
expect(screen.queryByText('Opslag: Niet beschikbaar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads the bounded container target scale and marks truncated services partial', async () => {
|
||||
const now = new Date().toISOString();
|
||||
const containers = Array.from({ length: 150 }, (_, index) => ({ id: `container-${String(index).padStart(3, '0')}`, state: 'running', health: 'healthy' }));
|
||||
const services = Array.from({ length: 100 }, (_, index) => ({ id: `service-${String(index).padStart(3, '0')}`, name: `Service ${index}`, state: 'up' }));
|
||||
let containerCalls = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const raw = String(input);
|
||||
const url = new URL(raw, 'http://pulse.test');
|
||||
if (raw === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'ok' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
|
||||
if (url.pathname === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.pathname === '/api/v1/containers') {
|
||||
containerCalls += 1;
|
||||
const offset = Number(url.searchParams.get('after') ?? 0);
|
||||
const page = containers.slice(offset, offset + 100);
|
||||
return json({ source: healthySource, containers: page, total: containers.length, nextCursor: offset + page.length < containers.length ? String(offset + page.length) : undefined });
|
||||
}
|
||||
if (url.pathname === '/api/v1/pools') return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
|
||||
if (url.pathname === '/api/v1/services') return json({ capabilityState: 'available', configurationState: 'configured', services, total: 150 });
|
||||
if (url.pathname === '/api/v1/incidents') return json({ items: [] });
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Workloads: Gezond.*150\/150/ })).toBeVisible();
|
||||
const serviceStage = screen.getByRole('button', { name: /Services: Gedeeltelijke data.*≥100\/150/ });
|
||||
expect(serviceStage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
|
||||
expect(screen.getByText('Services: Gedeeltelijke data')).toBeVisible();
|
||||
expect(containerCalls).toBe(2);
|
||||
});
|
||||
|
||||
it('bounds container paging and does not inflate totals with overlapping rows', async () => {
|
||||
const now = new Date().toISOString();
|
||||
let containerCalls = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const raw = String(input);
|
||||
const url = new URL(raw, 'http://pulse.test');
|
||||
if (raw === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
|
||||
if (url.pathname === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.pathname === '/api/v1/containers') {
|
||||
containerCalls += 1;
|
||||
const start = url.searchParams.get('after') === '100' ? 99 : url.searchParams.get('after') === '200' ? 199 : 0;
|
||||
const items = Array.from({ length: 100 }, (_, index) => ({ id: `container-${start + index}`, state: 'running', health: 'healthy' }));
|
||||
const nextCursor = containerCalls === 1 ? '100' : containerCalls === 2 ? '200' : '300';
|
||||
return json({ source: healthySource, containers: items, total: 400, nextCursor });
|
||||
}
|
||||
if (url.pathname === '/api/v1/pools') return json({ source: healthySource, pools: [], total: 0 });
|
||||
if (url.pathname === '/api/v1/services') return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.pathname === '/api/v1/incidents') return json({ items: [] });
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
const workloadStage = await screen.findByRole('button', { name: /Workloads: Gedeeltelijke data.*≥299\/400/ });
|
||||
expect(workloadStage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
|
||||
expect(screen.getByText('Workloads: Gedeeltelijke data')).toBeVisible();
|
||||
expect(containerCalls).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApplicationPage } from '../../src/ApplicationPage';
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe('application status projection', () => {
|
||||
it('uses attention for failures and unknown for incomplete evidence', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
source: { id: 'agent+services', state: 'healthy', freshness: 'fresh' },
|
||||
total: 2,
|
||||
applications: [
|
||||
{ id: 'a', name: 'attention-app', status: 'DOWN', overridden: false, components: [] },
|
||||
{ id: 'b', name: 'unknown-app', status: 'unknown', overridden: false, components: [] },
|
||||
],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ApplicationPage />);
|
||||
|
||||
const attention = (await screen.findByText('attention-app')).closest('li');
|
||||
const unknown = screen.getByText('unknown-app').closest('li');
|
||||
expect(attention).not.toBeNull();
|
||||
expect(unknown).not.toBeNull();
|
||||
expect(within(attention!).getByText('Aandacht').closest('.status-badge')).toHaveClass('status-badge--attention');
|
||||
expect(within(unknown!).getByText('Onbekend').closest('.status-badge')).toHaveClass('status-badge--unknown');
|
||||
});
|
||||
|
||||
it('vertaalt staleness en toont nooit een jaar-1-waarneming als echte tijd', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
source: { id: 'agent+services', state: 'healthy', freshness: 'stale', observedAt: '0001-01-01T00:00:00Z', reason: 'source_stale' },
|
||||
total: 0,
|
||||
applications: [],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ApplicationPage />);
|
||||
|
||||
expect(await screen.findByText(/laatste meting is verouderd/i)).toBeVisible();
|
||||
expect(screen.getByText('Nooit ontvangen')).toBeVisible();
|
||||
expect(screen.queryByText(/1 jan 1/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('source_stale')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ArrayPage } from '../../src/ArrayPage';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('ArrayPage', () => {
|
||||
it('renders a bounded empty state when an older snapshot contains null collections', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
|
||||
contractVersion: '1',
|
||||
source: { id: 'array', state: 'unknown', freshness: 'unavailable' },
|
||||
state: 'unknown',
|
||||
parity: { present: false, state: 'unknown', errors: 0 },
|
||||
members: null,
|
||||
history: null,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ArrayPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Array en parity' })).toBeVisible();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CapacityPage } from '../../src/CapacityPage';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('CapacityPage forecast qualification', () => {
|
||||
it('does not count an insufficient assessment as a forecast', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1', generatedAt: '2026-08-12T01:00:00Z',
|
||||
policy: { enabled: true, windowSeconds: 2592000, minPoints: 3, method: 'linear_median_rate' },
|
||||
qualifiedCount: 0,
|
||||
items: [{ entityId: 'media', name: 'Media', kind: 'share', enabled: true, method: 'insufficient_data', windowSeconds: 2592000, dataPoints: 1, confidence: 'none', currentUsedBytes: 100, capacityBytes: 0, rateBytesPerDay: 0, reason: 'insufficient_points' }],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<CapacityPage />);
|
||||
|
||||
expect(await screen.findByText(/0 gekwalificeerde prognoses/)).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: 'Media' })).toBeVisible();
|
||||
expect(screen.getByText('Er zijn minder historische metingen dan het ingestelde minimum.')).toBeVisible();
|
||||
expect(screen.queryByText('0 B / 0 B')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a concrete empty state without a synthetic entity', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1', generatedAt: '2026-08-12T01:00:00Z', reason: 'source_unavailable',
|
||||
policy: { enabled: true, windowSeconds: 2592000, minPoints: 3, method: 'linear_median_rate' },
|
||||
qualifiedCount: 0, items: [],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<CapacityPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Nog geen bruikbare capaciteitsprognose' })).toBeVisible();
|
||||
expect(screen.getByRole('link', { name: 'Bekijk shares en groeihistorie' })).toHaveAttribute('href', '/shares');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ContainerPage } from '../../src/ContainerPage';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('container status projection', () => {
|
||||
it('normalizes presentation and never fabricates missing health or metrics', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
source: { id: 'unraid', state: 'healthy', freshness: 'fresh' },
|
||||
total: 2,
|
||||
containers: [
|
||||
{ id: 'a', name: 'alpha', state: 'RUNNING', health: 'unknown', intentionalStop: false, metricsAvailable: false, lifecycleAvailable: false, uptimeSeconds: 0, restartCount: 0, exitCode: 0, cpuPercent: 0, memoryBytes: 0, memoryLimitBytes: 0, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 },
|
||||
{ id: 'b', name: 'beta', state: 'restarting', health: 'unhealthy', intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 60, restartCount: 4, exitCode: 137, cpuPercent: 12.5, memoryBytes: 1024, memoryLimitBytes: 2048, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 },
|
||||
],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ContainerPage />);
|
||||
|
||||
const alpha = (await screen.findAllByRole('link', { name: 'alpha' })).map((item) => item.closest('tr')).find(Boolean);
|
||||
const beta = screen.getAllByText('beta').map((item) => item.closest('tr')).find(Boolean);
|
||||
expect(alpha).not.toBeNull();
|
||||
expect(beta).not.toBeNull();
|
||||
expect(within(alpha!).getByText('Actief').closest('.status-badge')).toHaveClass('status-badge--ready');
|
||||
expect(within(alpha!).getAllByText('Onbekend').length).toBeGreaterThanOrEqual(2);
|
||||
expect(within(alpha!).getByText(/metingen niet beschikbaar/)).toBeVisible();
|
||||
expect(within(beta!).getByText('Wordt herstart').closest('.status-badge')).toHaveClass('status-badge--attention');
|
||||
expect(within(beta!).getByText('Ongezond').closest('.status-badge')).toHaveClass('status-badge--attention');
|
||||
expect(within(beta!).getByText('12,5%')).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not render stale item states as ready', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
source: { id: 'unraid', state: 'unknown', freshness: 'stale', reason: 'stale_source' },
|
||||
total: 1,
|
||||
containers: [{ id: 'a', name: 'stale-alpha', state: 'running', health: 'healthy', intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 60, restartCount: 0, exitCode: 0, cpuPercent: 10, memoryBytes: 1024, memoryLimitBytes: 2048, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 }],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ContainerPage />);
|
||||
|
||||
const row = (await screen.findAllByRole('link', { name: 'stale-alpha' })).map((item) => item.closest('tr')).find(Boolean);
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.querySelectorAll('.status-badge--unknown')).toHaveLength(2);
|
||||
expect(within(row!).queryByText('running')).not.toBeInTheDocument();
|
||||
expect(within(row!).queryByText('healthy')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from '../../src/App';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
describe('dashboard request caching', () => {
|
||||
it('does not reuse dashboard responses across authentication or onboarding changes', async () => {
|
||||
window.history.replaceState({}, '', '/dashboards');
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
requests.push({ url, init });
|
||||
if (url.startsWith('/api/v1/dashboards')) {
|
||||
return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({ error: 'not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Jouw dashboards' })).toBeVisible();
|
||||
const dashboardRequest = requests.find((request) => request.url.startsWith('/api/v1/dashboards'));
|
||||
expect(dashboardRequest?.init?.cache).toBe('no-store');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EventsPage } from '../../src/EventsPage';
|
||||
|
||||
const items = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `event-${String(index + 1).padStart(3, '0')}`,
|
||||
type: index % 2 === 0 ? 'service.down' : 'container.restart',
|
||||
severity: index % 10 === 0 ? 'critical' : index % 3 === 0 ? 'warning' : 'info',
|
||||
entityId: `entity-${String(index % 5).padStart(2, '0')}`,
|
||||
sourceId: 'source-unraid',
|
||||
occurredAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
|
||||
receivedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 5) - index * 60_000).toISOString(),
|
||||
summary: `Gebeurtenis ${String(index + 1).padStart(3, '0')}`,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, '', '/events');
|
||||
});
|
||||
|
||||
function mockEvents(payload = items) {
|
||||
const fetch = vi.fn(async () => new Response(JSON.stringify({ items: payload }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
return fetch;
|
||||
}
|
||||
|
||||
describe('compacte eventtijdlijn', () => {
|
||||
it('houdt honderd events begrensd en pagineert deterministisch met focus', async () => {
|
||||
const fetch = mockEvents();
|
||||
const user = userEvent.setup();
|
||||
render(<EventsPage />);
|
||||
|
||||
const list = await screen.findByRole('list', { name: 'Resultaten' });
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(20);
|
||||
expect(within(list).getByText('event-001')).toBeInTheDocument();
|
||||
expect(within(list).queryByText('event-021')).not.toBeInTheDocument();
|
||||
|
||||
const next = screen.getByRole('button', { name: 'Volgende pagina' });
|
||||
await user.click(next);
|
||||
await waitFor(() => expect(screen.getByText(/Pagina 2 van 5/)).toHaveFocus());
|
||||
expect(within(list).getByText('event-021')).toBeInTheDocument();
|
||||
expect(window.location.search).toContain('page=2');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('filtert ernst, soort, onderdeel en vrije tekst zonder nieuwe request', async () => {
|
||||
const fetch = mockEvents();
|
||||
const user = userEvent.setup();
|
||||
render(<EventsPage />);
|
||||
const list = await screen.findByRole('list', { name: 'Resultaten' });
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Ernst'), 'critical');
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
|
||||
await user.selectOptions(screen.getByLabelText('Soort'), 'service.down');
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
|
||||
await user.selectOptions(screen.getByLabelText('Onderdeel'), 'entity-00');
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
|
||||
await user.clear(screen.getByLabelText('Zoeken'));
|
||||
await user.type(screen.getByLabelText('Zoeken'), 'Gebeurtenis 091');
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(1);
|
||||
expect(within(list).getByText('event-091')).toBeInTheDocument();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('houdt het kritieke totaal zichtbaar en biedt een herstelbare lege state', async () => {
|
||||
mockEvents();
|
||||
const user = userEvent.setup();
|
||||
render(<EventsPage />);
|
||||
|
||||
const critical = await screen.findByRole('button', { name: /Kritieke gebeurtenissen/i });
|
||||
expect(critical).toHaveTextContent('10');
|
||||
await user.type(screen.getByLabelText('Zoeken'), 'bestaat-niet');
|
||||
expect(screen.getByText('Geen gebeurtenissen binnen de huidige filters.')).toBeVisible();
|
||||
expect(critical).toBeVisible();
|
||||
await user.click(screen.getByRole('button', { name: 'Alle filters wissen' }));
|
||||
expect(await screen.findByRole('list', { name: 'Resultaten' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { InventoryPage } from '../../src/InventoryPage';
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
const json = (value: unknown) => new Response(JSON.stringify(value), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
describe('InventoryPage', () => {
|
||||
it('renders effective status and sends bounded filters and pagination', async () => {
|
||||
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('after=')) return json({ items: [{ id: 'b', entityType: 'probe', canonicalName: 'probe.b', displayName: 'Probe B', status: 'unknown', factCount: 0, overrideCount: 0, relationCount: 0, sourceCount: 0, staleFactCount: 0 }], hasMore: false, nextCursor: '' });
|
||||
return json({ items: [{ id: 'a', entityType: 'container', canonicalName: 'container.a', displayName: 'Handmatige API', status: 'operational', factCount: 2, overrideCount: 1, relationCount: 1, sourceCount: 2, staleFactCount: 0 }], hasMore: true, nextCursor: 'next' });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
|
||||
render(<InventoryPage />);
|
||||
expect(await screen.findByText('Handmatige API')).toBeVisible();
|
||||
expect(screen.getByText('2 bronnen · 2 feiten · 1 relaties · 1 correcties')).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('Zoeken'), { target: { value: 'api' } });
|
||||
expect(await screen.findByText('Handmatige API')).toBeVisible();
|
||||
expect(fetch.mock.calls.some(([url]) => String(url).includes('q=api'))).toBe(true);
|
||||
expect(window.location.search).toContain('q=api');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Volgende pagina' }));
|
||||
expect(await screen.findByText('Probe B')).toBeVisible();
|
||||
expect(window.location.search).toContain('after=next');
|
||||
});
|
||||
|
||||
it('shows override priority while preserving discovered facts and stale relations', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => json({
|
||||
entity: { id: 'a', entityType: 'container', canonicalName: 'container.a', displayName: 'Handmatige API', status: 'operational', firstSeenAt: '2026-08-12T00:00:00Z', factCount: 2, overrideCount: 1, relationCount: 1, sourceCount: 2, staleFactCount: 1 },
|
||||
aliases: [{ sourceName: 'Unraid', externalType: 'container', externalId: 'api' }],
|
||||
facts: [{ fieldName: 'image', sourceId: 'unraid', sourceName: 'Unraid', value: 'discovered:latest', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }, { fieldName: 'runtimeState', sourceId: 'unraid', sourceName: 'Unraid', value: 'running', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }],
|
||||
overrides: [{ fieldName: 'image', value: 'manual:pinned', updatedAt: '2026-08-12T01:00:00Z' }],
|
||||
effectiveValues: [{ fieldName: 'image', value: 'manual:pinned', origin: 'override', stale: false, overriddenAt: '2026-08-12T01:00:00Z' }, { fieldName: 'runtimeState', value: 'running', origin: 'discovered', sourceName: 'Unraid', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }],
|
||||
relations: [{ id: 'r', direction: 'outgoing', relationType: 'depends_on', peerId: 'b', peerType: 'service', peerName: 'Database', peerStatus: 'Ontbrekende entiteit', peerTombstonedAt: '2026-08-11T00:00:00Z', sourceName: 'Agent', confidence: .8, confirmed: false }],
|
||||
})));
|
||||
|
||||
render(<InventoryPage id="a" />);
|
||||
const effective = await screen.findByRole('heading', { name: 'Wat Pulse momenteel gebruikt' });
|
||||
expect(within(effective.closest('section')!).getByText('manual:pinned')).toBeVisible();
|
||||
expect(screen.getByText('Handmatige correctie')).toBeVisible();
|
||||
expect(screen.getByText('Runtime-status')).toBeVisible();
|
||||
expect(screen.getByText('Actief')).toBeVisible();
|
||||
fireEvent.click(screen.getByText('Alle bronfeiten en correcties'));
|
||||
expect(screen.getByText(/discovered:latest/)).toBeVisible();
|
||||
expect(screen.getByText(/afgeleid/)).toBeVisible();
|
||||
expect(screen.getByText('Ontbrekende entiteit')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MetricWidget, RankedListWidget, StatusGridWidget, metricStatus, type MetricWidgetProps } from '../../src/MetricWidgets';
|
||||
|
||||
const now = Date.parse('2026-08-10T04:00:00Z');
|
||||
const freshPoint = { timestamp: now, value: 24, freshness: 'fresh' as const, labels: { host: 'tower' } };
|
||||
|
||||
function props(overrides: Partial<MetricWidgetProps> = {}): MetricWidgetProps {
|
||||
return {
|
||||
kind: 'stat',
|
||||
series: [{ key: 'tower', points: [freshPoint] }],
|
||||
freshness: 'fresh',
|
||||
expectedStepSeconds: 15,
|
||||
availability: 'success',
|
||||
metricName: 'host.cpu.utilization',
|
||||
visualization: { unit: 'percent', decimals: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('metricStatus ADR-0008 invariant', () => {
|
||||
it('marks only fresh, available data as ready', () => {
|
||||
expect(metricStatus(props())).toMatchObject({ tone: 'ready' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
props({ availability: 'idle' }),
|
||||
props({ availability: 'loading' }),
|
||||
props({ availability: 'connecting' }),
|
||||
props({ availability: 'error' }),
|
||||
props({ series: [] }),
|
||||
props({ freshness: 'stale' }),
|
||||
props({ freshness: 'unavailable' }),
|
||||
props({ series: [{ key: 'tower', points: [{ ...freshPoint, freshness: 'delayed' }] }] }),
|
||||
])('never presents missing, stale or unavailable telemetry as ready', (value) => {
|
||||
expect(metricStatus(value).tone).toBe('unknown');
|
||||
});
|
||||
|
||||
it('renders an explicit unavailable notice without a numeric value', () => {
|
||||
render(<MetricWidget {...props({ freshness: 'unavailable', series: [], availability: 'error', error: 'Bron tijdelijk onbereikbaar.' })} />);
|
||||
expect(screen.getByText('Bron tijdelijk onbereikbaar.')).toBeVisible();
|
||||
expect(screen.getByText(/De waarde wordt niet als gezond geïnterpreteerd/)).toBeVisible();
|
||||
expect(screen.queryByText('24 %')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive widgets', () => {
|
||||
it('presents metric series labels as human-readable values without raw JSON', () => {
|
||||
const rawKey = '{"interface":"eth0","host":"tower"}';
|
||||
render(<MetricWidget {...props({
|
||||
kind: 'timeseries',
|
||||
series: [{ key: rawKey, points: [
|
||||
{ ...freshPoint, timestamp: now - 15_000, labels: { __name__: 'host.network.receive', host: 'tower', interface: 'eth0' } },
|
||||
{ ...freshPoint, labels: { __name__: 'host.network.receive', host: 'tower', interface: 'eth0' } },
|
||||
] }],
|
||||
metricName: 'host.network.receive',
|
||||
})} />);
|
||||
|
||||
expect(screen.getByRole('list', { name: 'Legenda' })).toHaveTextContent('tower · eth0');
|
||||
expect(screen.getByRole('img', { name: /Tijdreeks voor Goedgekeurde meting/ })).toBeVisible();
|
||||
expect(document.querySelector('.metric-chart-line')).toHaveAttribute('d', 'M 28.000 192.000 L 628.000 192.000');
|
||||
expect(document.body).not.toHaveTextContent(rawKey);
|
||||
});
|
||||
|
||||
it('supports keyboard activation of ranked rows', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<RankedListWidget items={[{ id: 'disk-1', label: 'Disk 1', value: '78%' }]} onSelect={onSelect} />);
|
||||
const row = screen.getByRole('button', { name: /Disk 1/ });
|
||||
row.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
expect(onSelect).toHaveBeenCalledWith('disk-1');
|
||||
});
|
||||
|
||||
it('localizes status codes in status-grid fallbacks', () => {
|
||||
render(<StatusGridWidget items={[{ id: 'pool-1', label: 'Cachepool', status: 'degraded' }]} onSelect={vi.fn()} />);
|
||||
expect(screen.getByText('Verstoord')).toBeVisible();
|
||||
expect(screen.queryByText('degraded')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { NetworkPage } from '../../src/NetworkPage';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('NetworkPage API boundary', () => {
|
||||
it('normalizes nullable PostgreSQL collections before rendering', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1',
|
||||
observedAt: '2026-08-10T04:41:55Z',
|
||||
source: { id: 'agent', freshness: 'fresh', observedAt: '2026-08-10T04:41:55Z', state: 'unknown' },
|
||||
health: null,
|
||||
interfaces: null,
|
||||
certificates: null,
|
||||
events: null,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<NetworkPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Netwerk' })).toBeVisible();
|
||||
expect(screen.getByText('Geen betrouwbare interfacegegevens beschikbaar.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('distinguishes an unconfigured DNS signal from generic unknown', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1', observedAt: '2026-08-10T04:41:55Z',
|
||||
source: { id: 'network-aggregate', freshness: 'unknown', observedAt: '2026-08-10T04:41:55Z', state: 'unknown' },
|
||||
health: [{ scope: 'dns', state: 'unknown', capabilityState: 'available', configurationState: 'not_configured', reason: 'not_configured', freshness: 'unavailable', observedAt: '2026-08-10T04:41:55Z' }],
|
||||
interfaces: [], certificates: [], events: [],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<NetworkPage />);
|
||||
|
||||
expect(await screen.findByText('Niet geconfigureerd')).toBeVisible();
|
||||
expect(screen.getByText('Voor dit signaal is nog geen veilige probe geconfigureerd.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OnboardingPage } from '../../src/OnboardingPage';
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
const completed = {
|
||||
state: { completed: true, step: 'completed', dashboardChoice: 'default', rulesChoice: 'default', dashboardId: 'overview', rulesReady: true },
|
||||
capabilities: [
|
||||
{ id: 'auth', state: 'ready', detail: 'Aanmelding geconfigureerd.' },
|
||||
{ id: 'database', state: 'ready', detail: 'Database beschikbaar.' },
|
||||
],
|
||||
resume: false,
|
||||
};
|
||||
|
||||
describe('afgeronde onboarding', () => {
|
||||
it('toont eerst alleen de samenvatting en maakt herconfiguratie expliciet en rolbewust', async () => {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/system/status')) return Promise.resolve(new Response(JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
if (init?.method === 'POST') return Promise.resolve(new Response('{}', { status: 403 }));
|
||||
return Promise.resolve(new Response(JSON.stringify(completed), { 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(<OnboardingPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Pulse is geconfigureerd' })).toBeVisible();
|
||||
expect(screen.getByText('Overzicht actief')).toBeVisible();
|
||||
expect(screen.getByText('Standaardmeldingen actief')).toBeVisible();
|
||||
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Configuratie afronden' })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Herconfiguratie openen' }));
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(4);
|
||||
const save = screen.getByRole('button', { name: 'Herconfiguratie opslaan' });
|
||||
await user.click(save);
|
||||
expect(confirm).toHaveBeenCalled();
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false);
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
await user.click(save);
|
||||
expect(await screen.findByText('Alleen een beheerder kan onboardingkeuzes wijzigen.')).toBeVisible();
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OperationalSignalPath, type OperationalSignalStage } from '../../src/OperationalSignalPath';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const stages: OperationalSignalStage[] = [
|
||||
{
|
||||
id: 'host', label: 'Host', icon: '▣', tone: 'healthy', statusLabel: 'Gezond',
|
||||
primaryLabel: 'CPU-belasting', primaryValue: '42%', secondaryLabel: 'Geheugen', secondaryValue: '61%',
|
||||
detail: 'Actuele hostmeting.', route: '/host',
|
||||
},
|
||||
{
|
||||
id: 'storage', label: 'Opslag', icon: '▤', tone: 'critical', statusLabel: 'Kritiek',
|
||||
primaryLabel: 'Poolgebruik', primaryValue: '98%', secondaryLabel: 'Pools', secondaryValue: '2',
|
||||
detail: 'De capaciteitsgrens is overschreden.', route: '/storage',
|
||||
},
|
||||
];
|
||||
|
||||
describe('OperationalSignalPath', () => {
|
||||
it('selects the most urgent stage and supports explicit drill-down', async () => {
|
||||
const onNavigate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<OperationalSignalPath stages={stages} onNavigate={onNavigate} />);
|
||||
|
||||
const storage = screen.getByRole('button', { name: /Opslag: Kritiek/ });
|
||||
expect(storage).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('region', { name: 'Opslag' })).toHaveTextContent('98%');
|
||||
|
||||
const host = screen.getByRole('button', { name: /Host: Gezond/ });
|
||||
await user.click(host);
|
||||
expect(host).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('region', { name: 'Host' })).toHaveTextContent('Actuele hostmeting.');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open host' }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('/host');
|
||||
});
|
||||
|
||||
it('preserves an explicit keyboard selection across urgency updates', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<OperationalSignalPath stages={stages} onNavigate={vi.fn()} />);
|
||||
const host = screen.getByRole('button', { name: /Host: Gezond/ });
|
||||
host.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(host).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const updated = stages.map((stage) => stage.id === 'storage' ? { ...stage, tone: 'attention' as const, statusLabel: 'Aandacht' } : stage);
|
||||
rerender(<OperationalSignalPath stages={updated} onNavigate={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /Host: Gezond/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
rerender(<OperationalSignalPath stages={updated.filter((stage) => stage.id !== 'host')} onNavigate={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /Opslag: Aandacht/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PoolPage } from '../../src/PoolPage';
|
||||
|
||||
const criticalPool = {
|
||||
id: 'ssd', name: 'ssd', filesystem: 'zfs', state: 'healthy', usableBytes: 1000,
|
||||
usedBytes: 988, freeBytes: 12, utilizationPercent: 98.8, capacitySeverity: 'critical',
|
||||
capabilities: { members: 'available', capacity: 'available', redundancy: 'available', scrub: 'available', filesystemErrors: 'available', performance: 'available', ssdWear: 'available', moverSignals: 'available' },
|
||||
};
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe('operationele poolstatus', () => {
|
||||
it.each([
|
||||
{ id: undefined, payload: { source: { id: 'unraid', state: 'healthy', freshness: 'fresh' }, pools: [criticalPool], total: 1 } },
|
||||
{ id: 'ssd', payload: { source: { id: 'unraid', state: 'healthy', freshness: 'fresh' }, pools: [criticalPool], total: 1, pool: criticalPool } },
|
||||
])('toont kritieke capaciteit niet primair als gezond voor $id', async ({ id, payload }) => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
render(<PoolPage id={id} />);
|
||||
|
||||
expect(await screen.findByText('Kritiek')).toBeVisible();
|
||||
expect(screen.queryByText('Gezond', { selector: '.status-badge' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ServicePage } from '../../src/ServicePage';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('ServicePage API boundary', () => {
|
||||
it('renders a stable empty state when PostgreSQL serializes an empty service list as null', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1',
|
||||
observedAt: '2026-08-10T04:38:27Z',
|
||||
services: null,
|
||||
total: 0,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ServicePage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Services' })).toBeVisible();
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Nog geen services geconfigureerd' })).toBeVisible();
|
||||
expect(screen.getByRole('link', { name: 'Open eerste configuratie' })).toHaveAttribute('href', '/onboarding');
|
||||
});
|
||||
|
||||
it('does not present an unavailable source as an empty configuration', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1', observedAt: '2026-08-10T04:38:27Z', capabilityState: 'unavailable',
|
||||
configurationState: 'unknown', reason: 'source_unavailable', services: [], total: 0,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ServicePage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Servicebron niet beschikbaar' })).toBeVisible();
|
||||
expect(screen.queryByRole('link', { name: 'Open eerste configuratie' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the service-level TLS certificate when bounded probe history no longer contains the TLS sample', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/dependencies?limit=100')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({ serviceId: 'service-1', dependencies: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
service: {
|
||||
id: 'service-1', name: 'Pulse', state: 'up', sampleCount: 5, successfulSampleCount: 5,
|
||||
history: [{ probeId: 'http-probe', observedAt: '2026-08-12T10:20:00Z', state: 'up' }],
|
||||
certificate: { expiresAt: '2026-11-01T00:00:00Z', issuer: 'Pulse test issuer', subject: 'CN=pulse.test', hostnameValid: true, verificationState: 'valid' },
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}));
|
||||
|
||||
render(<ServicePage id="service-1" />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'TLS-certificaat' })).toBeVisible();
|
||||
expect(screen.getByText('Pulse test issuer')).toBeVisible();
|
||||
expect(screen.queryByText('Geen TLS-certificaat waargenomen.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SourceStatusDetails } from '../../src/SourceStatusDetails';
|
||||
|
||||
describe('SourceStatusDetails', () => {
|
||||
it('scheidt bronstatus van databruikbaarheid en houdt de code ingeklapt', () => {
|
||||
render(<SourceStatusDetails source={{ id: 'unraid', state: 'healthy', freshness: 'stale', observedAt: '2026-08-21T13:00:00Z', reason: 'source_stale' }} />);
|
||||
|
||||
expect(screen.getByText(/Bronstatus:/).closest('span')).toHaveTextContent('Gezond');
|
||||
expect(screen.getByText(/Data:/).closest('span')).toHaveTextContent('Verouderd');
|
||||
expect(screen.getByText(/laatste meting is verouderd/i)).toBeVisible();
|
||||
const code = screen.getByText('source_stale');
|
||||
expect(code.closest('details')).not.toHaveAttribute('open');
|
||||
expect(code).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('toont nooit ontvangen zonder een misleidend time-element', () => {
|
||||
const { container } = render(<SourceStatusDetails source={{ id: 'host', state: 'unknown', freshness: 'unavailable', observedAt: '0001-01-01T00:00:00Z' }} />);
|
||||
|
||||
expect(screen.getByText('Nooit ontvangen')).toBeVisible();
|
||||
expect(container.querySelector('time')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildStorageNodes, type StorageData } from '../../src/StoragePage';
|
||||
|
||||
function data(): StorageData {
|
||||
return {
|
||||
array: { source: { state: 'operational', freshness: 'fresh' }, state: 'operational', members: [{ id: 'disk-10', name: 'disk10', role: 'data', state: 'online' }] },
|
||||
disks: { source: { state: 'healthy', freshness: 'fresh' }, disks: [{ id: 'DISK-10', name: 'disk10', role: 'data', state: 'online', utilizationPercent: 99.99, capacitySeverity: 'critical', thermalSeverity: 'normal' }, { id: 'cache', name: 'cache', role: 'cache', state: 'online', utilizationPercent: 85.5, capacitySeverity: 'attention', thermalSeverity: 'critical' }] },
|
||||
pools: { source: { state: 'healthy', freshness: 'fresh' }, pools: [{ id: 'cache', name: 'cache', filesystem: 'zfs', state: 'healthy', utilizationPercent: 85.5, capacitySeverity: 'attention' }] },
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildStorageNodes', () => {
|
||||
it('merges array and disk observations by canonical physical identity', () => {
|
||||
const nodes = buildStorageNodes(data());
|
||||
expect(nodes.filter((node) => node.label === 'disk10')).toHaveLength(1);
|
||||
expect(nodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('keeps availability, capacity and thermal signals visible', () => {
|
||||
const nodes = buildStorageNodes(data());
|
||||
const disk = nodes.find((node) => node.label === 'disk10');
|
||||
const cacheDisk = nodes.find((node) => node.id === 'disk-cache');
|
||||
const cachePool = nodes.find((node) => node.id === 'pool-cache');
|
||||
expect(disk).toMatchObject({ state: 'critical' });
|
||||
expect(disk?.detail).toContain('Beschikbaarheid normaal · capaciteit kritiek · temperatuur normaal');
|
||||
expect(cacheDisk).toMatchObject({ state: 'critical' });
|
||||
expect(cachePool?.detail).toContain('Device-health normaal · capaciteit aandacht');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { StorageMapWidget, TemperatureHeatmap } from '../../src/StorageVisuals';
|
||||
|
||||
describe('storagevisualisaties', () => {
|
||||
it('localiseert statuscodes in de kaart en het toegankelijke tabelalternatief', () => {
|
||||
render(<StorageMapWidget title="Opslag" description="Status per onderdeel" nodes={[{ id: 'pool-1', label: 'Cachepool', kind: 'pool', state: 'degraded' }]} />);
|
||||
|
||||
expect(screen.getAllByText('Verstoord')).toHaveLength(2);
|
||||
expect(screen.getByRole('link', { name: 'Cachepool: Verstoord' })).toBeVisible();
|
||||
expect(screen.queryByText('degraded')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('localiseert de status in het tabelalternatief van de temperatuurkaart', () => {
|
||||
render(<TemperatureHeatmap title="Temperaturen" description="Recente metingen" points={[{ id: 'disk-1', label: 'Disk 1', observedAt: '2026-08-12T03:00:00Z', value: 48, status: 'critical' }]} />);
|
||||
|
||||
expect(screen.getByRole('cell', { name: 'Kritiek' })).toBeVisible();
|
||||
expect(screen.queryByRole('cell', { name: 'critical' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { apiRequestInit, installSessionWatcher, onUnauthenticated } from '../../src/auth';
|
||||
|
||||
const nativeFetch = window.fetch;
|
||||
afterEach(() => { window.fetch = nativeFetch; });
|
||||
|
||||
describe('authenticated API request caching', () => {
|
||||
it('forces same-origin API reads past stale pre-login responses', () => {
|
||||
expect(apiRequestInit('/api/v1/system/status')).toMatchObject({ cache: 'no-store' });
|
||||
expect(apiRequestInit(new URL('/api/v1/dashboards', window.location.href), { method: 'GET', cache: 'force-cache' })).toMatchObject({ method: 'GET', cache: 'no-store' });
|
||||
});
|
||||
|
||||
it('does not rewrite non-API or cross-origin requests', () => {
|
||||
const init = { method: 'GET' } satisfies RequestInit;
|
||||
expect(apiRequestInit('/assets/app.js', init)).toBe(init);
|
||||
expect(apiRequestInit('https://example.com/api/v1/status', init)).toBe(init);
|
||||
});
|
||||
|
||||
it('revokes the shared API context after one 401 and stops further network churn', async () => {
|
||||
const transport = vi.fn(async () => new Response(null, { status: 401 }));
|
||||
window.fetch = transport;
|
||||
const notices = vi.fn();
|
||||
const unsubscribe = onUnauthenticated(notices);
|
||||
installSessionWatcher();
|
||||
|
||||
expect((await window.fetch('/api/v1/system/status')).status).toBe(401);
|
||||
expect((await window.fetch('/api/v1/dashboards')).status).toBe(401);
|
||||
expect((await window.fetch('/api/v1/events')).status).toBe(401);
|
||||
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
expect(notices).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveDashboardScope } from '../../src/dashboardScope';
|
||||
|
||||
describe('resolveDashboardScope', () => {
|
||||
it('resolves exact declared defaults and drops non-string scope data', () => {
|
||||
expect(resolveDashboardScope(
|
||||
{ serverId: '$server', literal: 'disk-1', unsafe: 42 },
|
||||
[{ name: 'server', default: 'primary' }],
|
||||
)).toEqual({ serverId: 'primary', literal: 'disk-1' });
|
||||
});
|
||||
|
||||
it('preserves unresolved and partial references for fail-closed API validation', () => {
|
||||
expect(resolveDashboardScope(
|
||||
{ serverId: '$missing', containerId: 'prefix-$server' },
|
||||
[{ name: 'server', default: 'primary' }],
|
||||
)).toEqual({ serverId: '$missing', containerId: 'prefix-$server' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LiveClient, type SocketFactory } from '../../src/liveClient';
|
||||
import type { MetricQueryRequest } from '../../src/metricClient';
|
||||
|
||||
class FakeSocket {
|
||||
readyState = 0;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
readonly sent: string[] = [];
|
||||
|
||||
open(): void { this.readyState = 1; this.onopen?.(); }
|
||||
send(payload: string): void { this.sent.push(payload); }
|
||||
close(): void { this.readyState = 3; this.onclose?.(); }
|
||||
}
|
||||
|
||||
const request: MetricQueryRequest = {
|
||||
metric: 'host.cpu.utilization',
|
||||
scope: { serverId: 'smoke-host' },
|
||||
range: { from: '2026-08-10T06:00:00.000Z', to: '2026-08-10T06:05:00.000Z', stepSeconds: 15 },
|
||||
aggregation: 'avg',
|
||||
};
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('LiveClient subscription lifecycle', () => {
|
||||
it('reuses one socket when React replaces an equivalent listener inside the release grace', async () => {
|
||||
vi.useFakeTimers();
|
||||
const sockets: FakeSocket[] = [];
|
||||
const factory = vi.fn(() => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket;
|
||||
}) as unknown as SocketFactory;
|
||||
const client = new LiveClient('/api/v1/live', factory);
|
||||
|
||||
const first = client.subscribe(request, () => undefined);
|
||||
sockets[0].open();
|
||||
await Promise.resolve();
|
||||
first.unsubscribe();
|
||||
|
||||
const shifted = { ...request, range: { ...request.range, from: '2026-08-10T06:01:00.000Z', to: '2026-08-10T06:06:00.000Z' } };
|
||||
const second = client.subscribe(shifted, () => undefined);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(sockets[0].readyState).toBe(1);
|
||||
expect(sockets[0].sent.filter((payload) => payload.includes('unsubscribe'))).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(sockets[0].sent.some((payload) => payload.includes('"type":"ping"'))).toBe(true);
|
||||
|
||||
second.unsubscribe();
|
||||
await vi.advanceTimersByTimeAsync(251);
|
||||
expect(sockets[0].readyState).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(sockets[0].readyState).toBe(3);
|
||||
});
|
||||
|
||||
it('reuses an idle transport while a rotating dashboard loads its next query', async () => {
|
||||
vi.useFakeTimers();
|
||||
const sockets: FakeSocket[] = [];
|
||||
const factory = vi.fn(() => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket;
|
||||
}) as unknown as SocketFactory;
|
||||
const client = new LiveClient('/api/v1/live', factory);
|
||||
|
||||
const first = client.subscribe(request, () => undefined);
|
||||
sockets[0].open();
|
||||
await Promise.resolve();
|
||||
first.unsubscribe();
|
||||
|
||||
// Subscription state is released after 250 ms, but the bounded transport
|
||||
// grace bridges a slower dashboard document fetch.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(sockets[0].readyState).toBe(1);
|
||||
|
||||
const nextRequest = { ...request, metric: 'host.memory.utilization' };
|
||||
const second = client.subscribe(nextRequest, () => undefined);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(sockets[0].readyState).toBe(1);
|
||||
expect(sockets[0].sent.some((payload) => payload.includes('host.memory.utilization'))).toBe(true);
|
||||
|
||||
second.unsubscribe();
|
||||
await vi.advanceTimersByTimeAsync(10_251);
|
||||
expect(sockets[0].readyState).toBe(3);
|
||||
});
|
||||
|
||||
it('resubscribes once after reconnect and resumes samples on the bounded subscription', async () => {
|
||||
vi.useFakeTimers();
|
||||
const sockets: FakeSocket[] = [];
|
||||
const factory = vi.fn(() => { const socket = new FakeSocket(); sockets.push(socket); return socket; }) as unknown as SocketFactory;
|
||||
const events: string[] = [];
|
||||
const client = new LiveClient('/api/v1/live', factory);
|
||||
const subscription = client.subscribe(request, (event) => events.push(event.type));
|
||||
sockets[0].open();
|
||||
await Promise.resolve();
|
||||
expect(sockets[0].sent.filter((payload) => payload.includes('"type":"subscribe"'))).toHaveLength(1);
|
||||
|
||||
sockets[0].close();
|
||||
expect(events).toContain('status');
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
sockets[1].open();
|
||||
await Promise.resolve();
|
||||
const subscribe = JSON.parse(sockets[1].sent.find((payload) => payload.includes('"type":"subscribe"')) ?? '{}') as { subscriptionId?: string };
|
||||
expect(sockets[1].sent.filter((payload) => payload.includes('"type":"subscribe"'))).toHaveLength(1);
|
||||
sockets[1].onmessage?.({ data: JSON.stringify({ type: 'samples', subscriptionId: subscribe.subscriptionId, sequence: 1, samples: [{ timestamp: '2026-08-10T06:06:00.000Z', value: 42, labels: {} }] }) });
|
||||
expect(events.at(-1)).toBe('samples');
|
||||
expect(sockets).toHaveLength(2);
|
||||
|
||||
subscription.unsubscribe();
|
||||
await vi.advanceTimersByTimeAsync(10_251);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatDateTime, hasReceivedTimestamp, NEVER_RECEIVED } from '../../src/locale';
|
||||
|
||||
describe('veilige tijdpresentatie', () => {
|
||||
it('presenteert ontbrekende, ongeldige en nulwaarden als nooit ontvangen', () => {
|
||||
expect(formatDateTime()).toBe(NEVER_RECEIVED);
|
||||
expect(formatDateTime('ongeldig')).toBe(NEVER_RECEIVED);
|
||||
expect(formatDateTime('0001-01-01T00:00:00Z')).toBe(NEVER_RECEIVED);
|
||||
expect(formatDateTime('1970-01-01T00:00:00Z')).toBe(NEVER_RECEIVED);
|
||||
});
|
||||
|
||||
it('behoudt een werkelijk ontvangen timestamp en de vaste Brusselse tijdzone', () => {
|
||||
const value = '2026-08-21T14:05:00Z';
|
||||
expect(hasReceivedTimestamp(value)).toBe(true);
|
||||
expect(formatDateTime(value)).toContain('16:05');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { containerSignalTone, signalToneFromState, sourceSignalTone, worstSignalTone } from '../../src/overviewSignals';
|
||||
|
||||
describe('overview signal semantics', () => {
|
||||
it('fails source provenance closed when freshness is stale or unavailable', () => {
|
||||
expect(sourceSignalTone({ state: 'healthy', freshness: 'stale' })).toBe('stale');
|
||||
expect(sourceSignalTone({ state: 'healthy', freshness: 'unavailable' })).toBe('unknown');
|
||||
expect(sourceSignalTone({ state: 'degraded', freshness: 'fresh' })).toBe('attention');
|
||||
expect(sourceSignalTone({ state: 'healthy', freshness: 'fresh' })).toBe('healthy');
|
||||
expect(sourceSignalTone(undefined)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('mirrors container domain state without escalating intentional stops', () => {
|
||||
expect(containerSignalTone({ state: 'running', health: 'healthy' })).toBe('healthy');
|
||||
expect(containerSignalTone({ state: 'running', health: 'unhealthy' })).toBe('attention');
|
||||
expect(containerSignalTone({ state: 'restarting', health: 'unknown' })).toBe('attention');
|
||||
expect(containerSignalTone({ state: 'stopped', health: 'unknown' })).toBe('critical');
|
||||
expect(containerSignalTone({ state: 'stopped', health: 'unknown', intentionalStop: true })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('keeps the most severe state deterministically', () => {
|
||||
expect(signalToneFromState('DOWN')).toBe('critical');
|
||||
expect(worstSignalTone(['healthy', 'unknown', 'attention', 'stale'])).toBe('attention');
|
||||
expect(worstSignalTone([])).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { operationalStorageState, plural, presentArrayRole, presentComponent, presentEntityType, presentEventSummary, presentEventType, presentInventoryField, presentMetric, presentReason, presentRelationType, presentStatus, presentStoragePolicy, presentUnit } from '../../src/presentation';
|
||||
|
||||
describe('Nederlandse presentatielaag', () => {
|
||||
it('vertaalt begrensde status- en reason-codes', () => {
|
||||
expect(presentStatus('critical')).toBe('Kritiek');
|
||||
expect(presentReason('source_health_unknown')).toBe('De gezondheid van deze bron is onbekend.');
|
||||
expect(presentReason('source_stale')).toContain('meting is verouderd');
|
||||
expect(presentReason('filesystem_root_not_configured')).toContain('Bestandssysteemmetingen');
|
||||
expect(presentReason('container_exited')).toBe('De container is gestopt.');
|
||||
expect(presentReason('backup_verified')).toBe('De backup is geverifieerd.');
|
||||
expect(presentReason('backup_stale')).toContain('maak en verifieer een nieuwe backup');
|
||||
expect(presentReason('backup_verification_failed')).toContain('controleer de backupbestemming');
|
||||
expect(presentReason('authenticated_session')).toContain('aanmeldsessie is geldig');
|
||||
expect(presentReason('internal.unknown_code')).toContain('Open de technische details');
|
||||
});
|
||||
|
||||
it('laat de zwaarste opslagernst winnen zonder device-health te herschrijven', () => {
|
||||
expect(operationalStorageState('healthy', 'critical')).toBe('critical');
|
||||
expect(operationalStorageState('degraded', 'normal')).toBe('degraded');
|
||||
expect(operationalStorageState('healthy', 'normal')).toBe('healthy');
|
||||
});
|
||||
|
||||
it('presenteert componenten en metrics zonder implementatiecode', () => {
|
||||
expect(presentComponent('worker')).toBe('Achtergrondverwerking');
|
||||
expect(presentComponent('notifications')).toBe('Notificaties');
|
||||
expect(presentComponent('oidc')).toBe('Aanmelding');
|
||||
expect(presentComponent('probes')).toBe('Servicecontroles');
|
||||
expect(presentMetric('storage.disk.temperature')).toBe('Temperatuur per disk');
|
||||
expect(presentMetric('future.metric')).toBe('Goedgekeurde meting');
|
||||
expect(presentUnit('percent')).toBe('%');
|
||||
});
|
||||
|
||||
it('vertaalt operationele domeincodes voor primaire schermen', () => {
|
||||
expect(presentStatus('sleeping')).toBe('Slapend');
|
||||
expect(presentStatus('online')).toBe('Online');
|
||||
expect(presentStoragePolicy('HIGHWATER')).toBe('Hoogwater');
|
||||
expect(presentArrayRole('parity')).toBe('Pariteit');
|
||||
expect(presentEntityType('probe')).toBe('Servicecontrole');
|
||||
expect(presentEntityType('application-project')).toBe('Compose-project');
|
||||
expect(presentInventoryField('runtimeState')).toBe('Runtime-status');
|
||||
expect(presentRelationType('depends_on')).toBe('is afhankelijk van');
|
||||
expect(presentEventType('container.restart')).toBe('Container herstart');
|
||||
expect(presentEventSummary('container.restart', 'Container restarted.')).toBe('De container is opnieuw gestart.');
|
||||
});
|
||||
|
||||
it('gebruikt de correcte enkelvoudsvorm alleen voor één', () => {
|
||||
expect(plural(0, 'melding', 'meldingen')).toBe('meldingen');
|
||||
expect(plural(1, 'melding', 'meldingen')).toBe('melding');
|
||||
expect(plural(2, 'melding', 'meldingen')).toBe('meldingen');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from '../../src/App';
|
||||
import { routeFromLocation } from '../../src/routes';
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); window.history.replaceState({}, '', '/'); });
|
||||
|
||||
describe('expliciete routing', () => {
|
||||
it('behoudt de Events-route en projecteert onbekende adressen op 404', () => {
|
||||
expect(routeFromLocation('/events')).toBe('/events');
|
||||
expect(routeFromLocation('/bestaat-niet')).toBe('/404');
|
||||
});
|
||||
|
||||
it('toont een toegankelijke not-foundpagina in plaats van het overzicht', async () => {
|
||||
window.history.replaceState({}, '', '/bestaat-niet');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'unknown', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Deze pagina bestaat niet' })).toBeVisible();
|
||||
expect(screen.getByRole('link', { name: 'Naar overzicht' })).toHaveAttribute('href', '/');
|
||||
expect(screen.queryByRole('heading', { name: 'Status nog niet bevestigd' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { aggregateStatus, BACKUP_STALE_AFTER_SECONDS, backupPresentation, overviewTitle, STALE_AFTER_MS, statusProblems, type SystemStatus, type SystemStatusSnapshot } from '../../src/systemStatus';
|
||||
|
||||
const observedAt = Date.parse('2026-08-10T04:00:00Z');
|
||||
|
||||
function status(overrides: Partial<SystemStatus> = {}): SystemStatus {
|
||||
return {
|
||||
version: '1',
|
||||
generatedAt: new Date(observedAt).toISOString(),
|
||||
overallState: 'healthy',
|
||||
components: [],
|
||||
backup: { state: 'disabled', reason: 'not_configured' },
|
||||
sourceLag: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ready(value: SystemStatus): SystemStatusSnapshot {
|
||||
return { state: 'ready', status: value, fetchedAt: observedAt };
|
||||
}
|
||||
|
||||
describe('aggregateStatus ADR-0008 invariant', () => {
|
||||
it.each([
|
||||
{ state: 'loading', status: null, fetchedAt: 0 },
|
||||
{ state: 'error', status: null, fetchedAt: observedAt },
|
||||
{ state: 'unauthorized', status: null, fetchedAt: observedAt },
|
||||
{ state: 'forbidden', status: null, fetchedAt: observedAt },
|
||||
] satisfies SystemStatusSnapshot[])('maps $state without telemetry to Unknown', (snapshot) => {
|
||||
expect(aggregateStatus(snapshot, observedAt)).toMatchObject({ state: 'unknown', tone: 'unknown', stale: false });
|
||||
});
|
||||
|
||||
it('allows healthy only for a fresh ready payload that explicitly says healthy', () => {
|
||||
expect(aggregateStatus(ready(status()), observedAt + 1_000)).toMatchObject({ state: 'healthy', tone: 'ready', stale: false });
|
||||
});
|
||||
|
||||
it.each(['unknown', 'degraded', 'disabled'])('never maps %s to a ready tone', (overallState) => {
|
||||
expect(aggregateStatus(ready(status({ overallState })), observedAt + 1_000).tone).toBe('unknown');
|
||||
});
|
||||
|
||||
it('expires a formerly healthy payload to Unknown', () => {
|
||||
expect(aggregateStatus(ready(status()), observedAt + STALE_AFTER_MS + 1)).toMatchObject({ state: 'unknown', tone: 'unknown', stale: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusProblems', () => {
|
||||
it('keeps non-healthy signals bounded and omits disabled components', () => {
|
||||
const value = status({
|
||||
components: [
|
||||
{ id: 'database', state: 'healthy', reason: 'ok' },
|
||||
{ id: 'prometheus', state: 'unknown', reason: 'stale' },
|
||||
{ id: 'optional', state: 'disabled', reason: 'not_configured' },
|
||||
],
|
||||
sourceLag: Array.from({ length: 12 }, (_, index) => ({ sourceId: `source-${index}`, state: 'unknown', reason: 'missing' })),
|
||||
});
|
||||
const problems = statusProblems(value);
|
||||
expect(problems).toHaveLength(10);
|
||||
expect(problems[0]).toEqual({ id: 'component:prometheus', label: 'Prometheus', reason: 'De laatste meting is verouderd; controleer de bronverbinding en collector.' });
|
||||
expect(problems.some((problem) => problem.label === 'optional')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not repeat a source that is already represented by its component', () => {
|
||||
const value = status({
|
||||
components: [{ id: 'prometheus', state: 'unknown', reason: 'stale' }],
|
||||
sourceLag: [{ sourceId: 'prometheus', state: 'unknown', reason: 'stale' }],
|
||||
});
|
||||
expect(statusProblems(value)).toEqual([{ id: 'component:prometheus', label: 'Prometheus', reason: 'De laatste meting is verouderd; controleer de bronverbinding en collector.' }]);
|
||||
});
|
||||
|
||||
it('keeps a disabled required source actionable while omitting optional disabled features', () => {
|
||||
const value = status({ components: [
|
||||
{ id: 'unraid', state: 'disabled', reason: 'not_configured' },
|
||||
{ id: 'notifications', state: 'disabled', reason: 'not_configured' },
|
||||
] });
|
||||
expect(statusProblems(value)).toEqual([{ id: 'component:unraid', label: 'Unraid', reason: 'Dit onderdeel is nog niet geconfigureerd; open de instellingen om het te activeren.' }]);
|
||||
});
|
||||
|
||||
it('promotes an old allegedly healthy backup to an actionable problem', () => {
|
||||
const value = status({ backup: { state: 'healthy', reason: 'backup_verified', ageSeconds: BACKUP_STALE_AFTER_SECONDS + 1 } });
|
||||
expect(statusProblems(value)).toEqual([{ id: 'backup', label: 'Backupstatus', reason: 'De laatste geverifieerde backup is verlopen; maak en verifieer een nieuwe backup.' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupPresentation', () => {
|
||||
it('allows healthy only while a verified backup is within the 24-hour boundary', () => {
|
||||
expect(backupPresentation({ state: 'healthy', reason: 'backup_verified', ageSeconds: 3600 })).toMatchObject({ state: 'healthy', reason: 'backup_verified' });
|
||||
expect(backupPresentation({ state: 'healthy', reason: 'backup_verified', ageSeconds: BACKUP_STALE_AFTER_SECONDS + 1 })).toMatchObject({ state: 'degraded', reason: 'backup_stale' });
|
||||
});
|
||||
|
||||
it('fails closed when a healthy claim has no age or timestamp', () => {
|
||||
expect(backupPresentation({ state: 'healthy', reason: 'backup_verified' })).toMatchObject({ state: 'unknown', reason: 'no_verified_backup' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('overviewTitle', () => {
|
||||
it('reserves the optimistic title for a healthy issue-free snapshot', () => {
|
||||
const healthy = aggregateStatus(ready(status()), observedAt + 1_000);
|
||||
expect(overviewTitle(healthy, 0)).toBe('Alles onder controle');
|
||||
expect(overviewTitle(healthy, 1)).toBe('Aandacht vereist');
|
||||
expect(overviewTitle(healthy, 0, true)).toBe('Aandacht vereist');
|
||||
});
|
||||
|
||||
it('uses a conservative title for unknown telemetry', () => {
|
||||
const unknown = aggregateStatus(ready(status({ overallState: 'unknown' })), observedAt + 1_000);
|
||||
expect(overviewTitle(unknown, 0)).toBe('Status nog niet bevestigd');
|
||||
});
|
||||
|
||||
it('does not claim that no sources exist when an unknown status has live sources', () => {
|
||||
const unknown = aggregateStatus(ready(status({ overallState: 'unknown', sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh' }] })), observedAt + 1_000);
|
||||
expect(unknown.detail).toContain('Databronnen zijn verbonden');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { LiveClient } from '../../src/liveClient';
|
||||
import type { LiveSample } from '../../src/liveBuffer';
|
||||
import type { MetricQueryRequest } from '../../src/metricClient';
|
||||
import { useLiveMetric } from '../../src/useLiveMetric';
|
||||
|
||||
const request: MetricQueryRequest = {
|
||||
metric: 'host.cpu.utilization',
|
||||
range: { from: '2026-08-10T06:00:00.000Z', to: '2026-08-10T06:05:00.000Z', stepSeconds: 15 },
|
||||
aggregation: 'avg',
|
||||
};
|
||||
|
||||
function sample(timestamp: string, value: number): LiveSample {
|
||||
return { series: 'cpu', timestamp, value, freshness: 'fresh' };
|
||||
}
|
||||
|
||||
describe('useLiveMetric lifecycle', () => {
|
||||
it('updates historical seed data without recreating an unchanged live subscription', () => {
|
||||
const unsubscribe = vi.fn();
|
||||
const subscribe = vi.fn(() => ({ key: 'cpu', unsubscribe }));
|
||||
const releaseUnused = vi.fn();
|
||||
const client = { subscribe, releaseUnused } as unknown as LiveClient;
|
||||
|
||||
function Harness({ initial, query = request }: { initial: LiveSample[]; query?: MetricQueryRequest }) {
|
||||
useLiveMetric(client, query, initial);
|
||||
return null;
|
||||
}
|
||||
|
||||
const view = render(<Harness initial={[sample('2026-08-10T06:00:00.000Z', 10)]} />);
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
|
||||
view.rerender(<Harness initial={[
|
||||
sample('2026-08-10T06:00:00.000Z', 10),
|
||||
sample('2026-08-10T06:00:15.000Z', 11),
|
||||
]} />);
|
||||
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(unsubscribe).not.toHaveBeenCalled();
|
||||
|
||||
view.rerender(<Harness
|
||||
initial={[sample('2026-08-10T06:00:15.000Z', 11)]}
|
||||
query={{ ...request, range: { ...request.range, from: '2026-08-10T06:01:00.000Z', to: '2026-08-10T06:06:00.000Z' } }}
|
||||
/>);
|
||||
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(unsubscribe).not.toHaveBeenCalled();
|
||||
|
||||
view.unmount();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(releaseUnused).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { wallboardPlacement, wallboardSlideIndex } from '../../src/wallboardLayout';
|
||||
|
||||
describe('wallboard viewport layout', () => {
|
||||
it('splits the default 19-row layout into two deterministic 1080p slides', () => {
|
||||
expect([0, 6, 12, 13, 18].map(wallboardSlideIndex)).toEqual([0, 0, 0, 1, 1]);
|
||||
});
|
||||
|
||||
it('keeps every widget inside the 24 by 13 slide grid', () => {
|
||||
expect(wallboardPlacement({ x: 23, y: 12, w: 12, h: 8 })).toEqual({ columnStart: 24, columnSpan: 1, rowStart: 13, rowSpan: 1 });
|
||||
expect(wallboardPlacement({ x: -4, y: 13, w: 0, h: 0 })).toEqual({ columnStart: 1, columnSpan: 1, rowStart: 1, rowSpan: 1 });
|
||||
});
|
||||
|
||||
it('uses safe defaults for malformed coordinates', () => {
|
||||
expect(wallboardSlideIndex('not-a-row')).toBe(0);
|
||||
expect(wallboardPlacement({ x: Number.POSITIVE_INFINITY, y: null, w: 'wide', h: -2 }))
|
||||
.toEqual({ columnStart: 1, columnSpan: 6, rowStart: 1, rowSpan: 1 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user