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 [ ...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' } }) }, ]; } /** Сводка сервера для ответов `GET /guilds/{id}`. */ function guildDetail(id: string, name: string, ownerId: string, permissions: string[]) { return { id, name, description: '', owner_id: ownerId, is_main: false, member_count: 1, roles: [], my_role_ids: [], my_permissions: permissions, }; } /** Серверы пользователя: «Альфа» своя, «Бета» с правом MANAGE_GUILD. */ function guildRoute(): FetchRoute[] { const alpha = guildDetail('g-1', 'Альфа', 'user-1', []); const beta = guildDetail('g-2', 'Бета', 'user-9', ['MANAGE_GUILD']); return [ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) }, { match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) }, { match: '/api/v1/guilds/g-2/members', response: () => json({ members: [] }) }, { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/guilds/g-2/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/guilds/g-1', response: () => json({ guild: alpha }) }, { match: '/api/v1/guilds/g-2', response: () => json({ guild: beta }) }, ]; } /** Открывает окно настроек шестерёнкой в панели пользователя. */ async function openSettings(): Promise { const gear = await screen.findByLabelText('Настройки пользователя'); await userEvent.click(gear); return screen.findByRole('dialog', { name: 'Настройки' }); } /** Список пунктов окна настроек. */ function settingsNav(dialog: HTMLElement): HTMLElement { return within(dialog).getByTestId('settings-nav'); } /** Открывает пункт «Сервер» (администрирование) в левом меню настроек. */ async function openServerSection(dialog: HTMLElement): Promise { await userEvent.click(await within(settingsNav(dialog)).findByRole('button', { 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(settingsNav(dialog)).queryByRole('button', { name: 'Сервер' })).toBeNull(); }); it('владелец сервера открывает администрирование этого сервера', async () => { installFetch(appRoutes(user, guildRoute())); renderApp('/app/empty'); const dialog = await openSettings(); // Пункт появляется, когда снапшот сессии узнал серверы пользователя. await openServerSection(dialog); // Выбран сервер пользователя: его общие настройки и «Опасная зона». expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа'); expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Альфа'); expect(within(dialog).getByRole('button', { name: 'Удалить сервер' })).toBeVisible(); }); it('администратор инстанса видит пункт «Сервер» без своих серверов', async () => { installFetch(appRoutes(makeUser({ is_instance_admin: true }))); renderApp('/app/empty'); const dialog = await openSettings(); expect( await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }), ).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(user, guildRoute())); renderApp('/settings/servers'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); expect( await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }), ).toHaveAttribute('aria-current', 'true'); // Открылся первый сервер списка. expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа'); }); it('/settings/servers/g-2 открывает администрирование выбранного сервера', async () => { installFetch(appRoutes(user, guildRoute())); renderApp('/settings/servers/g-2'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Бета'); expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Бета'); }); }); 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('профиль сохраняется без часового пояса и статуса: у них свои места', async () => { const fetchMock = installFetch( appRoutes(user, [ { match: '/api/v1/users/@me', method: 'PATCH', response: () => json({ user }), }, ]), ); renderApp('/app/empty'); const dialog = await openSettings(); expect(within(dialog).queryByLabelText('Часовой пояс')).toBeNull(); expect(within(dialog).queryByLabelText('Статус присутствия')).toBeNull(); // ID не показываем и не копируем в пользовательских настройках. expect(within(dialog).queryByText('ID пользователя')).toBeNull(); expect(within(dialog).queryByRole('button', { name: 'Скопировать ID' })).toBeNull(); expect(within(dialog).getByLabelText('Отображаемое имя')).toHaveValue('Alice'); await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' })); await waitFor(() => { expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({ display_name: 'Alice', bio: '', custom_status: '', custom_status_emoji: '', }); }); }); it('часовой пояс есть ровно в одном пункте — «Язык и регион»', async () => { installFetch(appRoutes()); renderApp('/app/empty'); const dialog = await openSettings(); for (const item of ['Профиль', 'Внешний вид', 'Уведомления', 'Аудио-видео']) { await goToSection(dialog, item); expect(within(dialog).queryByLabelText('Часовой пояс')).toBeNull(); } await goToSection(dialog, 'Язык и регион'); expect(within(dialog).getAllByLabelText('Часовой пояс')).toHaveLength(1); }); it('аватар стоит слева от полей, клик по нему открывает выбор файла', async () => { const fetchMock = installFetch( appRoutes(user, [ { match: '/api/v1/users/@me/avatar', method: 'POST', response: () => json({ user: { ...user, avatar_file_id: 'file-9' } }), }, ]), ); renderApp('/app/empty'); const dialog = await openSettings(); // Аватар — слева: кнопка смены идёт в DOM раньше поля имени. const avatar = within(dialog).getByTestId('profile-avatar'); const displayName = within(dialog).getByLabelText('Отображаемое имя'); expect(avatar.compareDocumentPosition(displayName)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); // Подсказка о форматах — в title и доступном имени кнопки, а не текстом. const change = within(dialog).getByRole('button', { name: /Сменить аватар/ }); expect(change).toHaveAttribute('title', expect.stringContaining('JPEG')); expect(within(dialog).queryByText(/PNG, JPEG, WebP/)).toBeNull(); // Клик по аватару открывает выбор файла: он же отправляет multipart. const input = within(dialog).getByLabelText('Выбрать файл аватара'); await userEvent.upload(input, new File(['x'], 'avatar.png', { type: 'image/png' })); expect(change).toBeVisible(); expect(document.querySelector('input[type="file"]')).not.toBeNull(); await waitFor(() => { expect(avatarForm(fetchMock, 'POST')).not.toBeNull(); }); }); it('размер окна настроек фиксирован и не зависит от содержимого', async () => { installFetch(appRoutes()); renderApp('/app/empty'); const dialog = await openSettings(); const panel = dialog; const style = panel.getAttribute('style') ?? ''; expect(style).toContain('clamp(20rem, 70vw, 72rem)'); expect(style).toContain('clamp(22rem, 70vh, 52rem)'); expect(panel).toHaveClass('overflow-hidden'); expect(screen.getByTestId('settings-content')).toHaveClass('overflow-y-auto'); // Переключение пункта не меняет размеры окна. const before = panel.getAttribute('style'); await goToSection(dialog, 'Безопасность'); expect(panel.getAttribute('style')).toBe(before); }); it('ID пользователя в «Инстансе» виден администратору', async () => { installFetch( appRoutes(makeUser({ is_instance_admin: true }), [ { match: '/api/v1/instance/settings', response: () => json({ settings: {} }) }, { match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/instance/users', response: () => json({ users: [ { id: 'u-42', username: 'alice', display_name: 'Alice', is_instance_admin: true, created_at: '2026-09-01T00:00:00Z', banned: false, }, ], total: 1, }), }, { match: '/api/v1/instance/audit', response: () => json({ entries: [] }) }, ]), ); renderApp('/app/empty'); const dialog = await openSettings(); await goToSection(dialog, 'Инстанс'); // Админ-панель разбита на вкладки: пользователи живут в своей. await userEvent.click(await within(dialog).findByTestId('instance-tab-users')); expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42'); expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible(); }); it('глобальный бан требует причину и step-up, разбан возвращает доступ', async () => { const other = { id: 'u-7', username: 'bob', display_name: 'Bob', is_instance_admin: false, created_at: '2026-09-02T00:00:00Z', banned: false, }; const bannedUser = { ...other, id: 'u-8', username: 'mallory', display_name: 'Mallory', banned: true, ban_reason: 'спам', }; const banRequests: { url: string; body: unknown }[] = []; const listUrls: string[] = []; const fetchMock = installFetch( appRoutes(makeUser({ is_instance_admin: true }), [ { match: '/api/v1/instance/settings', response: () => json({ settings: {} }) }, { match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/instance/audit', response: () => json({ entries: [] }) }, { match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }), }, { match: '/api/v1/instance/users/u-7/ban', method: 'POST', response: (request) => { banRequests.push({ url: request.url, body: request.body }); return json({ user: {} }); }, }, { match: '/api/v1/instance/users', response: (request) => { listUrls.push(request.url); return json({ users: [other, bannedUser], total: 2 }); }, }, ]), ); renderApp('/app/empty'); const dialog = await openSettings(); await goToSection(dialog, 'Инстанс'); await userEvent.click(await within(dialog).findByTestId('instance-tab-users')); // Забаненный помечен, у админа и себя кнопка бана недоступна. expect(await within(dialog).findByTestId('instance-user-banned-u-8')).toHaveTextContent( 'Забанен', ); expect(within(dialog).getByTestId('instance-user-ban-u-7')).toBeEnabled(); // Бан: сначала причина, затем подтверждение личности. await userEvent.click(within(dialog).getByTestId('instance-user-ban-u-7')); const reasonForm = await within(dialog).findByTestId('instance-user-ban-form-u-7'); await userEvent.type(within(reasonForm).getByLabelText('Причина бана'), 'флуд'); await userEvent.click(within(reasonForm).getByRole('button', { name: 'Забанить' })); const stepUpForm = await within(dialog).findByTestId('instance-step-up'); await userEvent.type(within(stepUpForm).getByLabelText('Ваш пароль'), 'correct-horse-battery'); await userEvent.click(within(stepUpForm).getByRole('button', { name: 'Подтвердить' })); await waitFor(() => expect(banRequests).toHaveLength(1)); expect(banRequests[0]?.url).toContain('/instance/users/u-7/ban'); expect(banRequests[0]?.body).toEqual({ reason: 'флуд' }); // Поиск уходит на сервер параметром q, фильтр — banned=true. const search = within(dialog).getByLabelText('Поиск'); await userEvent.type(search, 'bob'); await waitFor(() => expect(listUrls.some((url) => url.includes('q=bob'))).toBe(true)); await userEvent.click(within(dialog).getByTestId('instance-users-banned-only')); await waitFor(() => expect(listUrls.some((url) => url.includes('banned=true'))).toBe(true)); expect(fetchMock).toHaveBeenCalled(); }); }); 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'); // Серверы инстанса. await userEvent.click(await screen.findByTestId('instance-tab-guilds')); expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main'); // Пользователи инстанса. await userEvent.click(screen.getByTestId('instance-tab-users')); expect(await screen.findByTestId('instance-users')).toHaveTextContent('Alice'); // Журнал действий администраторов. await userEvent.click(screen.getByTestId('instance-tab-audit')); expect(await screen.findByTestId('instance-audit')).toHaveTextContent('guild.create'); // Лимиты: панель показывает значения из настроек. await userEvent.click(screen.getByTestId('instance-tab-limits')); expect(await screen.findByTestId('instance-limits')).toBeVisible(); }); }); describe('админ-панель: действия', () => { it('сбрасывает пароль, выкидывает со всех устройств и удаляет пользователя', async () => { vi.spyOn(window, 'confirm').mockReturnValue(true); const admin = makeUser({ is_instance_admin: true }); const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user: admin }) }, { match: '/api/v1/instance/settings', response: () => json({ settings: {} }) }, { match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/instance/users/u-7/reset-password', method: 'POST', response: () => json({ user_id: 'u-7', password: 'Temp-Pass-123456', sessions_revoked: 2 }), }, { match: '/api/v1/instance/users/u-7/logout', method: 'POST', response: () => json({ ok: true }), }, { match: '/api/v1/instance/users/u-7', method: 'DELETE', response: () => json({ ok: true }), }, { match: '/api/v1/instance/users', response: () => json({ users: [ { id: 'u-7', username: 'bob', display_name: 'Bob', is_instance_admin: false, created_at: '2026-09-01T00:00:00Z', }, ], }), }, { match: '/api/v1/instance/audit', response: () => json({ entries: [] }) }, ]); renderApp('/settings/account/instance'); await userEvent.click(await screen.findByTestId('instance-tab-users')); const panel = await screen.findByTestId('instance-users-actions'); // Временный пароль показывается один раз. await userEvent.click(within(panel).getByTestId('instance-user-reset-u-7')); expect(await screen.findByTestId('instance-temp-password')).toHaveTextContent( 'Temp-Pass-123456', ); // Выход со всех устройств. await userEvent.click(within(panel).getByTestId('instance-user-logout-u-7')); await waitFor(() => { expect( recordedRequests(fetchMock).some( (request) => request.method === 'POST' && request.url.includes('/users/u-7/logout'), ), ).toBe(true); }); // Мягкое удаление. await userEvent.click(within(panel).getByTestId('instance-user-delete-u-7')); await waitFor(() => { expect( recordedRequests(fetchMock).some( (request) => request.method === 'DELETE' && request.url.endsWith('/users/u-7'), ), ).toBe(true); }); }); it('переименовывает сервер инстанса', async () => { const admin = makeUser({ is_instance_admin: true }); const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user: admin }) }, { match: '/api/v1/instance/settings', response: () => json({ settings: {} }) }, { match: '/api/v1/instance/guilds/g-1', method: 'PATCH', response: () => json({ ok: true }), }, { match: '/api/v1/instance/guilds', response: () => json({ guilds: [{ id: 'g-1', name: 'Старое', member_count: 3, owner_id: 'u', is_main: false }], }), }, { match: '/api/v1/instance/users', response: () => json({ users: [] }) }, { match: '/api/v1/instance/audit', response: () => json({ entries: [] }) }, ]); renderApp('/settings/account/instance'); await userEvent.click(await screen.findByTestId('instance-tab-guilds')); const panel = await screen.findByTestId('instance-guilds-actions'); await userEvent.click(within(panel).getByTestId('instance-guild-rename-g-1')); const nameInput = screen.getByLabelText('Новое имя сервера'); await userEvent.clear(nameInput); await userEvent.type(nameInput, 'Новое'); await userEvent.click(screen.getByRole('button', { name: 'Сохранить' })); await waitFor(() => { const request = recordedRequests(fetchMock).find( (entry) => entry.method === 'PATCH' && entry.url.includes('/instance/guilds/g-1'), ); expect(request?.body).toMatchObject({ name: 'Новое' }); }); }); });