import { describe, expect, it, vi } from 'vitest'; import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { apiError, findRequest, installFetch, json, makeUser, recordedRequests, renderApp, requestUrl, type FetchRoute, } from './helpers'; const user = makeUser(); const sessions = [ { id: 's-1', user_agent: 'Firefox on Linux', ip: '10.0.0.2', created_at: '2026-09-01T10:00:00Z', last_seen: '2026-09-19T10:00:00Z', current: true, }, { id: 's-2', user_agent: 'Chrome on macOS', ip: '10.0.0.3', created_at: '2026-09-02T10:00:00Z', last_seen: '2026-09-18T10:00:00Z', current: false, }, ]; /** Ответы оболочки приложения: профиль, серверы, инстанс. */ function appRoutes(current = user, extra: FetchRoute[] = []): FetchRoute[] { return [ { 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' } }) }, ...extra, ]; } /** Открывает окно настроек шестерёнкой в панели пользователя. */ async function openSettings(): Promise { const gear = await screen.findByLabelText('Настройки пользователя'); await userEvent.click(gear); return screen.findByRole('dialog', { name: 'Настройки' }); } /** Переходит к пункту настроек по его названию в левом меню. */ async function goToSection(dialog: HTMLElement, name: string): Promise { await userEvent.click(within(dialog).getByRole('button', { name })); } /** Тело multipart-запроса аватара (или null, если запроса не было). */ function avatarForm(fetchMock: ReturnType, method: string): FormData | null { const call = fetchMock.mock.calls.find( ([input, init]) => requestUrl(input).includes('/users/@me/avatar') && (init?.method ?? 'GET').toUpperCase() === method, ); const body = call?.[1]?.body; return body instanceof FormData ? body : null; } describe('настройки: окно и навигация', () => { it('открывается шестерёнкой, показывает пункты и закрывается по Escape, крестику и подложке', async () => { installFetch(appRoutes()); renderApp('/app/empty'); const dialog = await openSettings(); for (const item of [ 'Профиль', 'Безопасность', 'Внешний вид', 'Уведомления', 'Аудио-видео', 'Язык и регион', ]) { expect(within(dialog).getByRole('button', { name: item })).toBeVisible(); } expect(within(dialog).getByRole('button', { name: 'Профиль' })).toHaveAttribute( 'aria-current', 'true', ); // Escape. await userEvent.keyboard('{Escape}'); await waitFor(() => { expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull(); }); // Крестик. await userEvent.click(await screen.findByLabelText('Настройки пользователя')); const reopened = await screen.findByRole('dialog', { name: 'Настройки' }); await userEvent.click(within(reopened).getByRole('button', { name: 'Закрыть' })); await waitFor(() => { expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull(); }); // Клик по подложке. const gear = await screen.findByLabelText('Настройки пользователя'); await userEvent.click(gear); await screen.findByRole('dialog', { name: 'Настройки' }); fireEvent.mouseDown(screen.getByTestId('modal-backdrop')); await waitFor(() => { expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull(); }); }); it('переключает вкладки «Основной» и «Сервер» внутри профиля', async () => { installFetch(appRoutes()); renderApp('/app/empty'); const dialog = await openSettings(); const mainTab = within(dialog).getByRole('tab', { name: 'Основной' }); const serverTab = within(dialog).getByRole('tab', { name: 'Сервер' }); expect(mainTab).toHaveAttribute('aria-selected', 'true'); expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible(); await userEvent.click(serverTab); expect(serverTab).toHaveAttribute('aria-selected', 'true'); expect( within(screen.getByTestId('settings-content')).getByText('Пока нет ни одного сервера'), ).toBeVisible(); await userEvent.click(within(dialog).getByRole('tab', { name: 'Основной' })); expect(mainTab).toHaveAttribute('aria-selected', 'true'); expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible(); }); it('пункт «Инстанс» видят только администраторы', async () => { installFetch(appRoutes()); renderApp('/app/empty'); const dialog = await openSettings(); expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull(); }); it('«Уведомления» пока заглушка', async () => { installFetch(appRoutes()); renderApp('/app/empty'); const dialog = await openSettings(); await goToSection(dialog, 'Уведомления'); expect(within(dialog).getByText('Настройки уведомлений появятся позже.')).toBeVisible(); }); it('«Аудио-видео» без mediaDevices честно сообщает о недоступности', async () => { installFetch(appRoutes()); renderApp('/app/empty'); const dialog = await openSettings(); await goToSection(dialog, 'Аудио-видео'); expect(within(dialog).getAllByText('Устройства недоступны').length).toBeGreaterThan(0); expect(within(dialog).getByLabelText('Громкость микрофона')).toBeVisible(); expect(within(dialog).getByLabelText('Громкость звука')).toBeVisible(); }); it('«Язык и регион» сохраняет часовой пояс через PATCH /users/@me', async () => { const fetchMock = installFetch( appRoutes(user, [ { match: '/api/v1/users/@me', method: 'PATCH', response: () => json({ user: { ...user, timezone: 'Europe/Berlin' } }), }, ]), ); renderApp('/app/empty'); const dialog = await openSettings(); await goToSection(dialog, 'Язык и регион'); await userEvent.selectOptions(within(dialog).getByLabelText('Часовой пояс'), 'Europe/Berlin'); await waitFor(() => { expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({ timezone: 'Europe/Berlin', }); }); expect(await within(dialog).findByText('Настройки региона сохранены.')).toBeVisible(); }); }); describe('настройки: старые адреса', () => { it.each([ ['/settings', 'Профиль', 'Основной'], ['/settings/account/profile', 'Профиль', 'Основной'], ['/settings/profile', 'Профиль', 'Основной'], ])('%s открывает окно на «%s → %s»', async (from, section, tab) => { installFetch(appRoutes()); const { router } = renderApp(from); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); await waitFor(() => { expect(router.state.location.pathname).toContain('/app'); }); expect(within(dialog).getByRole('button', { name: section })).toHaveAttribute( 'aria-current', 'true', ); expect(within(dialog).getByRole('tab', { name: tab })).toHaveAttribute('aria-selected', 'true'); }); it.each([ ['/settings/security', 'Безопасность'], ['/settings/account/security', 'Безопасность'], ['/settings/appearance', 'Внешний вид'], ['/settings/account/appearance', 'Внешний вид'], ])('%s открывает окно на пункте «%s»', async (from, section) => { installFetch(appRoutes()); renderApp(from); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); expect(within(dialog).getByRole('button', { name: section })).toHaveAttribute( 'aria-current', 'true', ); }); it('/settings/servers открывает вкладку «Сервер»', async () => { installFetch(appRoutes()); renderApp('/settings/servers'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute( 'aria-selected', 'true', ); }); }); describe('настройки: аватар', () => { it('загружает файл через POST /users/@me/avatar и удаляет его', async () => { const fetchMock = installFetch( appRoutes(user, [ { match: '/api/v1/users/@me/avatar', method: 'POST', response: () => json({ user: { ...user, avatar_file_id: 'file-9' } }), }, { match: '/api/v1/users/@me/avatar', method: 'DELETE', response: () => json({ user: { ...user } }), }, ]), ); renderApp('/app/empty'); await openSettings(); const input = await screen.findByLabelText('Выбрать файл аватара'); const file = new File(['картинка'], 'avatar.png', { type: 'image/png' }); await userEvent.upload(input, file); await waitFor(() => { expect(avatarForm(fetchMock, 'POST')).not.toBeNull(); }); // Multipart с полем `file`, как ждёт сервер. const form = avatarForm(fetchMock, 'POST'); expect((form?.get('file') as File | null)?.name).toBe('avatar.png'); // Ответ сервера обновил профиль: аватар отдаётся файловым сервисом. await waitFor(() => { expect(document.querySelector('img[src="/files/file-9"]')).not.toBeNull(); }); await userEvent.click(screen.getByRole('button', { name: 'Удалить аватар' })); await waitFor(() => { expect( recordedRequests(fetchMock).some( (request) => request.url.includes('/users/@me/avatar') && request.method === 'DELETE', ), ).toBe(true); }); }); it('слишком большой файл не отправляется, показывается ошибка', async () => { const fetchMock = installFetch(appRoutes()); renderApp('/app/empty'); await openSettings(); const input = await screen.findByLabelText('Выбрать файл аватара'); const big = new File(['x'], 'big.png', { type: 'image/png' }); Object.defineProperty(big, 'size', { value: 9 * 1024 * 1024 }); await userEvent.upload(input, big); expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше'); expect(avatarForm(fetchMock, 'POST')).toBeNull(); }); it('ошибка сервера file.too_large показывается понятным текстом', async () => { installFetch( appRoutes(user, [ { match: '/api/v1/users/@me/avatar', method: 'POST', response: () => apiError('file.too_large', 413), }, ]), ); renderApp('/app/empty'); await openSettings(); const input = await screen.findByLabelText('Выбрать файл аватара'); await userEvent.upload(input, new File(['x'], 'avatar.png', { type: 'image/png' })); expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше допустимого размера'); }); it('часовой пояс уходит в PATCH вместе с профилем', async () => { const fetchMock = installFetch( appRoutes(user, [ { match: '/api/v1/users/@me', method: 'PATCH', response: () => json({ user: { ...user, timezone: 'Asia/Tokyo' } }), }, ]), ); renderApp('/app/empty'); await openSettings(); await userEvent.selectOptions(screen.getByLabelText('Часовой пояс'), 'Asia/Tokyo'); await userEvent.click(screen.getByRole('button', { name: 'Сохранить' })); await waitFor(() => { expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toMatchObject({ timezone: 'Asia/Tokyo', }); }); expect(await screen.findByText('Профиль сохранён.')).toBeVisible(); }); }); describe('настройки: безопасность', () => { it('включение 2FA показывает секрет и коды восстановления', async () => { const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/auth/sessions', response: () => json({ sessions }) }, { match: '/api/v1/auth/2fa/setup', method: 'POST', response: () => json({ secret: 'JBSWY3DPEHPK3PXP', otpauth_url: 'otpauth://totp/glchat:alice?secret=X', // QR-код рисует сервер и отдаёт data-URI: внешние сервисы не нужны. qr_png: 'data:image/png;base64,iVBORw0KGgo=', }), }, { match: '/api/v1/auth/2fa/enable', method: 'POST', response: () => json({ recovery_codes: ['aaaa-bbbb', 'cccc-dddd'] }), }, ]); renderApp('/settings/account/security'); const visitor = userEvent.setup(); await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' })); expect(await screen.findByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP'); expect(screen.getByAltText('QR-код для настройки 2FA')).toBeVisible(); await visitor.type(screen.getByLabelText('Код из приложения'), '123456'); await visitor.click(screen.getByRole('button', { name: 'Включить' })); const codes = await screen.findByTestId('recovery-codes'); expect(codes).toHaveTextContent('aaaa-bbbb'); expect(codes).toHaveTextContent('cccc-dddd'); expect(screen.getByText('2FA включена. Сохраните коды восстановления.')).toBeVisible(); expect(findRequest(fetchMock, { url: '/auth/2fa/enable', method: 'POST' })?.body).toEqual({ code: '123456', }); }); it('logout-all вызывается по кнопке и уводит на /login', async () => { const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/auth/sessions', response: () => json({ sessions }) }, { match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) }, ]); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); const { router } = renderApp('/settings/account/security'); const visitor = userEvent.setup(); expect(await screen.findByTestId('sessions-list')).toHaveTextContent('Firefox on Linux'); await visitor.click(screen.getByRole('button', { name: 'Выйти на всех устройствах' })); await waitFor(() => { expect(router.state.location.pathname).toBe('/login'); }); expect(confirm).toHaveBeenCalled(); expect( recordedRequests(fetchMock).some( (request) => request.url.includes('/auth/logout-all') && request.method === 'POST', ), ).toBe(true); }); it('смена пароля требует step-up и повторяется после подтверждения', async () => { let passwordAttempts = 0; installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/auth/sessions', response: () => json({ sessions }) }, { match: '/api/v1/users/@me/password', method: 'POST', response: () => { passwordAttempts += 1; return passwordAttempts === 1 ? apiError('auth.step_up_required', 403) : json({ ok: true }); }, }, { match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) }, ]); renderApp('/settings/account/security'); const visitor = userEvent.setup(); await visitor.type(await screen.findByLabelText('Текущий пароль'), 'old-password-1'); await visitor.type(screen.getByLabelText('Новый пароль'), 'new-password-1'); await visitor.type(screen.getByLabelText('Повторите новый пароль'), 'new-password-1'); await visitor.click(screen.getByRole('button', { name: 'Сменить пароль' })); // Сервер ответил auth.step_up_required — появилась форма подтверждения. expect(await screen.findByText('Подтвердите личность')).toBeVisible(); await visitor.type(screen.getByLabelText('Ваш пароль'), 'old-password-1'); await visitor.click(screen.getByRole('button', { name: 'Подтвердить' })); expect(await screen.findByText('Пароль изменён.')).toBeVisible(); }); it('профиль сохраняется через PATCH /users/@me', async () => { const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/users/@me', method: 'PATCH', response: () => json({ user: { ...user, display_name: 'Алиса' } }), }, ]); renderApp('/settings/account/profile'); const visitor = userEvent.setup(); const displayName = await screen.findByLabelText('Отображаемое имя'); await visitor.clear(displayName); await visitor.type(displayName, 'Алиса'); await visitor.click(screen.getByRole('button', { name: 'Сохранить' })); expect(await screen.findByText('Профиль сохранён.')).toBeVisible(); const patchCall = findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' }); expect(patchCall?.body).toMatchObject({ display_name: 'Алиса' }); }); it('администратор видит данные инстанса', async () => { const admin = makeUser({ is_instance_admin: true }); installFetch([ { match: '/api/v1/users/@me', response: () => json({ user: admin }) }, { match: '/api/v1/instance/settings', response: () => json({ settings: { motd: 'привет' } }), }, { match: '/api/v1/instance/guilds', response: () => json({ guilds: [{ id: 'g-1', name: 'Main', member_count: 3, owner_id: 'u', is_main: true }], }), }, { match: '/api/v1/instance/users', response: () => json({ users: [ { id: 'u-1', username: 'alice', display_name: 'Alice', is_instance_admin: true, created_at: '2026-09-01T00:00:00Z', }, ], }), }, { match: '/api/v1/instance/audit', response: () => json({ entries: [ { id: 'a-1', actor_id: 'u-1', action: 'guild.create', created_at: '2026-09-01T00:00:00Z', }, ], }), }, ]); renderApp('/settings/account/instance'); expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main'); expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice'); expect(screen.getByTestId('instance-audit')).toHaveTextContent('guild.create'); expect(screen.getByText('motd')).toBeVisible(); }); });