import { describe, expect, it } from 'vitest'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers'; /** Метрики инстанса в формате ручки `GET /instance/metrics`. */ function metricsPayload(overrides: Record = {}) { return { metrics: { cpu: { percent: 23.4, cores: 4, source: 'cgroup' }, memory: { used_bytes: 805_306_368 / 4, total_bytes: 805_306_368, percent: 25, source: 'cgroup', }, disk: { used_bytes: 12 * 1024 ** 3, total_bytes: 24 * 1024 ** 3, free_bytes: 12 * 1024 ** 3, }, database: { bytes: 1_572_864, wal_bytes: 262_144 }, checks: [ { name: 'app', status: 'ok', message: 'v0.1.0' }, { name: 'database', status: 'ok', message: 'schema_version=10' }, { name: 'disk', status: 'ok', message: '50.0% free' }, { name: 'storage', status: 'ok' }, { name: 'voice', status: 'ok', message: 'RoomService' }, { name: 'gateway', status: 'ok', message: 'sessions=3' }, ], health: 'ok', uptime_seconds: 3600, version: 'v0.1.0', commit: 'abc1234', collected_at: '2026-09-20T18:00:00.123Z', ...overrides, }, }; } function appRoutes(current = makeUser(), extra: FetchRoute[] = []): FetchRoute[] { return [ ...extra, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/users/@me', response: () => json({ user: current }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, ]; } /** adminRoutes: раздел «Инстанс» виден только администратору инстанса. */ function adminRoutes(metrics: unknown = metricsPayload()): FetchRoute[] { return appRoutes(makeUser({ is_instance_admin: true }), [ { match: '/api/v1/instance/metrics', response: () => json(metrics) }, { match: '/api/v1/instance/settings', response: () => json({ settings: {} }) }, { match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/instance/users', response: () => json({ users: [] }) }, { match: '/api/v1/instance/audit', response: () => json({ entries: [] }) }, ]); } /** openInstance открывает настройки и переходит на вкладку «Инстанс». */ async function openInstance(): Promise { const gear = await screen.findByLabelText('Настройки пользователя'); await userEvent.click(gear); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); await userEvent.click(await within(dialog).findByRole('button', { name: 'Инстанс' })); return dialog; } describe('дашборд инстанса', () => { it('показывает полукруглые индикаторы процессора и памяти', async () => { installFetch(adminRoutes()); renderApp('/app/empty'); const dialog = await openInstance(); const cpu = await within(dialog).findByTestId('gauge-cpu'); expect(cpu).toHaveAttribute('role', 'meter'); expect(within(cpu).getByTestId('gauge-cpu-value')).toHaveTextContent('23,4 %'); // Полукруг заполняется пропорционально: 23.4 % от длины дуги. const arc = within(cpu).getByTestId('gauge-cpu-arc'); const [filled, total] = (arc.getAttribute('stroke-dasharray') ?? '').split(' '); expect(Number(filled)).toBeGreaterThan(0); expect(Number(filled) / Number(total)).toBeCloseTo(0.234, 2); const memory = within(dialog).getByTestId('gauge-memory'); expect(within(memory).getByTestId('gauge-memory-value')).toHaveTextContent('25 %'); expect(memory).toHaveAttribute('aria-valuenow', '25'); // Подпись памяти — занято / всего. expect(within(memory).getByText(/192 MB \/ 768 MB/)).toBeVisible(); }); it('показывает диск и базу без подписей — только в тултипах', async () => { installFetch(adminRoutes()); renderApp('/app/empty'); const dialog = await openInstance(); const disk = await within(dialog).findByTestId('disk-usage'); const diskValue = within(disk).getByTestId('disk-usage-value'); expect(diskValue).toHaveTextContent('12 GB / 24 GB'); expect(diskValue).toHaveAttribute('title', expect.stringContaining('Диск: занято / всего')); expect(diskValue).toHaveAttribute('title', expect.stringContaining('12 GB')); const database = within(dialog).getByTestId('database-size'); const databaseValue = within(database).getByTestId('database-size-value'); // База = основной файл + журнал WAL (1.5 MB + 0.25 MB) → 1,8 MB // (formatBytes оставляет одну цифру после запятой для значений < 10). expect(databaseValue).toHaveTextContent('1,8 MB'); expect(databaseValue).toHaveAttribute('title', expect.stringContaining('База данных')); // Видимых подписей у блоков нет: только значения. expect(disk.textContent).toBe(diskValue.textContent); expect(database.textContent).toBe(databaseValue.textContent); }); it('показывает healthcheck и живой пинг', async () => { installFetch(adminRoutes()); renderApp('/app/empty'); const dialog = await openInstance(); const health = await within(dialog).findByTestId('instance-health'); expect(within(health).getByTestId('instance-health-summary')).toHaveTextContent( 'Всё в порядке', ); expect(within(health).getByTestId('instance-check-app')).toHaveAttribute('data-status', 'ok'); expect(within(health).getByTestId('instance-check-database')).toHaveTextContent( 'schema_version=10', ); expect(within(health).getByTestId('instance-check-voice')).toHaveTextContent('работает'); expect(within(health).getByTestId('instance-check-gateway')).toHaveTextContent('Gateway'); // Пинг измеряется на запросе дашборда и показывается в миллисекундах. await waitFor(() => { expect(within(health).getByTestId('instance-ping').getAttribute('data-ping')).not.toBe(''); }); expect(within(health).getByTestId('instance-ping')).toHaveTextContent('Пинг'); expect(within(health).getByTestId('instance-ping').textContent).toMatch(/\d+ мс/); }); it('деградирует мягко: предупреждения и ошибки видно в списке', async () => { installFetch( adminRoutes( metricsPayload({ health: 'failed', checks: [ { name: 'app', status: 'ok' }, { name: 'database', status: 'failed', message: 'database is locked' }, { name: 'disk', status: 'warn', message: '11.0% free' }, ], cpu: { percent: 0, cores: 0, source: '' }, memory: { used_bytes: 0, total_bytes: 0, percent: 0, source: '' }, }), ), ); renderApp('/app/empty'); const dialog = await openInstance(); const health = await within(dialog).findByTestId('instance-health'); expect(within(health).getByTestId('instance-health-summary')).toHaveTextContent('Есть ошибки'); expect(within(health).getByTestId('instance-check-database')).toHaveAttribute( 'data-status', 'failed', ); expect(within(health).getByTestId('instance-check-disk')).toHaveAttribute( 'data-status', 'warn', ); }); it('обычный пользователь дашборд не видит', async () => { installFetch(appRoutes(makeUser({ is_instance_admin: false }))); renderApp('/app/empty'); // Кнопка настроек в панели пользователя есть всегда, а раздел «Инстанс» — // только у администратора, поэтому проверяем отсутствие пункта. const gear = await screen.findByLabelText('Настройки пользователя'); await userEvent.click(gear); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); await waitFor(() => { expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull(); }); }); });