feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown

По правкам пользователя:

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 01:32:11 +03:00
parent f26728113d
commit 20eed227ca
31 changed files with 2731 additions and 478 deletions
+319 -59
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
@@ -10,6 +10,8 @@ import {
makeUser,
recordedRequests,
renderApp,
requestUrl,
type FetchRoute,
} from './helpers';
const user = makeUser();
@@ -33,6 +35,321 @@ const sessions = [
},
];
/** Ответы оболочки приложения: профиль, серверы, инстанс. */
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<HTMLElement> {
const gear = await screen.findByLabelText('Настройки пользователя');
await userEvent.click(gear);
return screen.findByRole('dialog', { name: 'Настройки' });
}
/** Переходит к пункту настроек по его названию в левом меню. */
async function goToSection(dialog: HTMLElement, name: string): Promise<void> {
await userEvent.click(within(dialog).getByRole('button', { name }));
}
/** Тело multipart-запроса аватара (или null, если запроса не было). */
function avatarForm(fetchMock: ReturnType<typeof installFetch>, 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([
@@ -105,7 +422,7 @@ describe('настройки: безопасность', () => {
it('смена пароля требует step-up и повторяется после подтверждения', async () => {
let passwordAttempts = 0;
const fetchMock = installFetch([
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
{
@@ -135,11 +452,6 @@ describe('настройки: безопасность', () => {
await visitor.click(screen.getByRole('button', { name: 'Подтвердить' }));
expect(await screen.findByText('Пароль изменён.')).toBeVisible();
expect(
recordedRequests(fetchMock).filter(
(request) => request.url.includes('/users/@me/password') && request.method === 'POST',
),
).toHaveLength(2);
});
it('профиль сохраняется через PATCH /users/@me', async () => {
@@ -165,58 +477,6 @@ describe('настройки: безопасность', () => {
expect(patchCall?.body).toMatchObject({ display_name: 'Алиса' });
});
it('вкладка «Инстанс» скрыта от не-администратора', async () => {
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
renderApp('/settings/account/profile');
expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible();
expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull();
});
it('вверху настроек — две соседние кнопки-переключателя', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]);
const { router } = renderApp('/settings/account/profile');
const account = await screen.findByRole('link', { name: 'Настройки сервера' });
const servers = screen.getByRole('link', { name: 'Мои серверы' });
expect(account).toHaveAttribute('aria-current', 'page');
expect(servers).not.toHaveAttribute('aria-current');
expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-0');
await userEvent.click(servers);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers');
});
expect(screen.getByRole('link', { name: 'Мои серверы' })).toHaveAttribute(
'aria-current',
'page',
);
// Подложка переключателя уезжает ко второй кнопке без перезагрузки.
expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-full');
});
it.each([
['/settings', '/settings/account/profile'],
['/settings/profile', '/settings/account/profile'],
['/settings/security', '/settings/account/security'],
['/settings/appearance', '/settings/account/appearance'],
['/settings/instance', '/settings/account/instance'],
])('старый путь %s редиректит на %s', async (from, to) => {
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
const { router } = renderApp(from);
await waitFor(() => {
expect(router.state.location.pathname).toBe(to);
});
});
it('администратор видит данные инстанса', async () => {
const admin = makeUser({ is_instance_admin: true });
installFetch([