feat(files): загрузка и выдача вложений

- POST /api/v1/channels/{id}/files (multipart, ATTACH_FILES): файл пишется на
  диск под идентификатором, в БД хранятся метаданные и sha256; имя файла
  очищается от путей и управляющих символов, объём ограничен MAX_UPLOAD_SIZE;
- GET/HEAD /files/{id}: содержимое с проверкой прав на комнату сообщения
  (невидимая комната и чужие вложения → 404), ETag, immutable-кэш,
  Content-Disposition с RFC 5987 для не-ASCII имён;
- вложения привязываются к сообщению при отправке (attachment_ids), сироты
  ищутся через store.ListOrphanFiles для обслуживания;
- GET /api/v1/files/{id} — метаданные файла для клиента;
- chi-ручки теперь умеют отдавать huma-ошибки в общем конверте
  (writeHumaAPIError), иначе 404 превращался в 500;
- READY отдаёт реальные read states пользователя.

Тесты: загрузка, скачивание участником, скрытая комната → 404, вложение в
сообщении, проверка подписи ETag.
This commit is contained in:
2026-09-19 23:36:16 +03:00
parent 63bf1e63b5
commit 0e39b8e027
25 changed files with 1162 additions and 39 deletions
+9 -2
View File
@@ -172,6 +172,11 @@ interface SnapshotGuildInput {
id: string;
name: string;
is_main?: boolean;
/** Владелец сервера: по умолчанию текущий пользователь (`user-1`). */
owner_id?: string;
icon_file_id?: string;
my_permissions?: string[];
my_nickname?: string;
channels?: {
id: string;
name: string;
@@ -196,8 +201,10 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
guilds: guilds.map((guild) => ({
id: guild.id,
name: guild.name,
owner_id: 'user-1',
owner_id: guild.owner_id ?? 'user-1',
is_main: guild.is_main ?? false,
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
...(guild.my_nickname === undefined ? {} : { my_nickname: guild.my_nickname }),
channels: (guild.channels ?? []).map((channel, index) => ({
id: channel.id,
name: channel.name,
@@ -210,7 +217,7 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
roles: [],
member_ids: ['user-1'],
my_role_ids: [],
my_permissions: [],
my_permissions: guild.my_permissions ?? [],
})),
dm_channels: [],
read_states: [],
+308
View File
@@ -0,0 +1,308 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
type FetchRoute,
} from './helpers';
import type { GuildMember, GuildSummary, Role } from '@/api/types';
const user = makeUser();
const alpha: GuildSummary = {
id: 'g-1',
name: 'Альфа',
icon_file_id: 'icon-1',
owner_id: 'user-1',
is_main: false,
member_count: 2,
my_role_ids: [],
my_permissions: ['MANAGE_GUILD'],
};
const beta: GuildSummary = {
id: 'g-2',
name: 'Бета',
owner_id: 'user-9',
is_main: false,
member_count: 1,
my_role_ids: [],
my_permissions: [],
};
const members: GuildMember[] = [
{
user_id: 'user-1',
username: 'alice',
display_name: 'Alice',
nickname: 'Аля',
status: 'online',
is_instance_admin: false,
joined_at: '2026-09-01T10:00:00Z',
role_ids: ['r-1'],
},
{
user_id: 'user-2',
username: 'bob',
display_name: 'Bob',
status: 'idle',
is_instance_admin: false,
joined_at: '2026-09-02T10:00:00Z',
role_ids: [],
},
];
const roles: Role[] = [
{
id: 'r-1',
name: 'Администратор',
color: 0xff0000,
position: 5,
permissions: 'ADMINISTRATOR',
is_default: false,
hoist: true,
mentionable: true,
},
{
id: 'r-0',
name: 'Все',
color: 0,
position: 0,
permissions: '0',
is_default: true,
hoist: false,
mentionable: false,
},
];
/** Ответ `GET /guilds/{id}`: сводка + описание и роли. */
function guildDetail(summary: GuildSummary, description: string) {
return { ...summary, description, roles };
}
/** Таблица моков страницы настроек серверов. */
function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] {
return [
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ 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/members', response: () => json({ members }) },
{ match: '/api/v1/guilds/g-2/members', response: () => json({ members: [members[1]] }) },
{
match: '/api/v1/guilds/g-1',
response: () => json({ guild: guildDetail(alpha, 'Первый сервер') }),
},
{
match: '/api/v1/guilds/g-2',
response: () => json({ guild: guildDetail(beta, 'Второй сервер') }),
},
...extra,
];
}
/** Первая кнопка «Сохранить» на странице (секция «Общее»). */
function saveButton(index = 0): HTMLElement {
const buttons = screen.getAllByRole('button', { name: 'Сохранить' });
const button = buttons[index];
if (button === undefined) {
throw new Error('кнопка сохранения не найдена');
}
return button;
}
describe('настройки серверов', () => {
it('показывает список серверов с иконкой и именем и выбирает первый', async () => {
installFetch(serversRoutes());
const { router } = renderApp('/settings/servers');
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers/g-1');
});
// После выбора первого сервера страница перерисовывается — берём список заново.
const picker = await screen.findByTestId('guild-picker');
const first = within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' });
expect(first).toHaveAttribute('aria-current', 'page');
// Иконка сервера отдаётся файловым сервисом по icon_file_id.
expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1');
expect(
within(picker).getByRole('link', { name: 'Открыть настройки сервера Бета' }),
).toBeVisible();
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
});
it('переключение сервера меняет отображаемые настройки и права', async () => {
installFetch(serversRoutes());
const { router } = renderApp('/settings/servers/g-1');
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
// Владелец может редактировать.
expect(screen.getByLabelText('Название сервера')).toBeEnabled();
await userEvent.click(
await screen.findByRole('link', { name: 'Открыть настройки сервера Бета' }),
);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers/g-2');
});
await waitFor(() => {
expect(screen.getByLabelText('Название сервера')).toHaveValue('Бета');
});
// Участник без прав видит поля только для чтения и подсказку.
expect(screen.getByLabelText('Название сервера')).toBeDisabled();
expect(screen.getByText(/Редактирование доступно владельцу/)).toBeVisible();
});
it('сохраняет имя и описание сервера через PATCH /guilds/{id}', async () => {
const fetchMock = installFetch(
serversRoutes([
{
match: '/api/v1/guilds/g-1',
method: 'PATCH',
response: () => json({ guild: guildDetail({ ...alpha, name: 'Альфа 2' }, 'Новое') }),
},
]),
);
renderApp('/settings/servers/g-1');
const name = await screen.findByLabelText('Название сервера');
await userEvent.clear(name);
await userEvent.type(name, 'Альфа 2');
const description = screen.getByLabelText('Описание');
await userEvent.clear(description);
await userEvent.type(description, 'Новое');
await userEvent.click(saveButton());
expect(await screen.findByText('Настройки сервера сохранены.')).toBeVisible();
expect(findRequest(fetchMock, { url: '/guilds/g-1', method: 'PATCH' })?.body).toEqual({
name: 'Альфа 2',
description: 'Новое',
});
});
it('сохраняет свой никнейм через PATCH /guilds/{id}/members/{me}', async () => {
const fetchMock = installFetch(
serversRoutes([
{
match: '/api/v1/guilds/g-1/members/user-1',
method: 'PATCH',
response: () => json({ ok: true }),
},
]),
);
renderApp('/settings/servers/g-1');
const nickname = await screen.findByLabelText('Никнейм');
await waitFor(() => {
expect(nickname).toHaveValue('Аля');
});
await userEvent.clear(nickname);
await userEvent.type(nickname, 'Алиса');
// Вторая кнопка «Сохранить» — секция «Мой профиль на сервере».
await userEvent.click(saveButton(1));
expect(await screen.findByText('Никнейм сохранён.')).toBeVisible();
const patch = recordedRequests(fetchMock).find(
(request) => request.url.includes('/members/user-1') && request.method === 'PATCH',
);
expect(patch?.body).toEqual({ nickname: 'Алиса' });
});
it('показывает участников и роли сервера', async () => {
installFetch(serversRoutes());
renderApp('/settings/servers/g-1');
const list = await screen.findByTestId('guild-members');
expect(within(list).getByText('Аля')).toBeVisible();
expect(within(list).getByText('Bob')).toBeVisible();
expect(within(list).getByText('Ролей: 1')).toBeVisible();
const roleList = await screen.findByTestId('guild-roles');
expect(within(roleList).getByText('Администратор')).toBeVisible();
expect(within(roleList).getByText('Позиция 5')).toBeVisible();
});
it('владелец не может покинуть сервер, но может его удалить', async () => {
installFetch(serversRoutes());
renderApp('/settings/servers/g-1');
expect(await screen.findByText(/Владелец не может покинуть свой сервер/)).toBeVisible();
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
expect(screen.getByRole('button', { name: 'Удалить сервер' })).toBeVisible();
});
it('участник без прав не видит удаление сервера', async () => {
installFetch(serversRoutes());
renderApp('/settings/servers/g-2');
expect(await screen.findByRole('button', { name: 'Покинуть сервер' })).toBeVisible();
expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull();
});
it('покинуть сервер можно после подтверждения в модальном окне', async () => {
const fetchMock = installFetch(
serversRoutes([
{ match: '/api/v1/guilds/g-2/leave', method: 'POST', response: () => json({ ok: true }) },
]),
);
const { router } = renderApp('/settings/servers/g-2');
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
expect(dialog).toHaveTextContent('Вы выйдете из сервера «Бета»');
await userEvent.click(within(dialog).getByRole('button', { name: 'Покинуть' }));
await waitFor(() => {
expect(router.state.location.pathname).toContain('/app');
});
expect(findRequest(fetchMock, { url: '/guilds/g-2/leave', method: 'POST' })).toBeDefined();
});
it('удаление сервера владельцем уходит в DELETE /guilds/{id}', async () => {
const fetchMock = installFetch(
serversRoutes([
{ match: '/api/v1/guilds/g-1', method: 'DELETE', response: () => json({ ok: true }) },
]),
);
const { router } = renderApp('/settings/servers/g-1');
await userEvent.click(await screen.findByRole('button', { name: 'Удалить сервер' }));
const dialog = await screen.findByRole('dialog', { name: 'Удалить сервер?' });
await userEvent.click(within(dialog).getByRole('button', { name: 'Удалить' }));
await waitFor(() => {
expect(router.state.location.pathname).toContain('/app');
});
expect(findRequest(fetchMock, { url: '/guilds/g-1', method: 'DELETE' })).toBeDefined();
});
it('без серверов показывает понятную заглушку', async () => {
installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
]);
renderApp('/settings/servers');
expect(await screen.findByText('Пока нет ни одного сервера')).toBeVisible();
expect(screen.getByText('У вас пока нет серверов.')).toBeVisible();
});
});
+49 -6
View File
@@ -56,7 +56,7 @@ describe('настройки: безопасность', () => {
},
]);
renderApp('/settings/security');
renderApp('/settings/account/security');
const visitor = userEvent.setup();
await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' }));
@@ -85,7 +85,7 @@ describe('настройки: безопасность', () => {
]);
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { router } = renderApp('/settings/security');
const { router } = renderApp('/settings/account/security');
const visitor = userEvent.setup();
expect(await screen.findByTestId('sessions-list')).toHaveTextContent('Firefox on Linux');
@@ -121,7 +121,7 @@ describe('настройки: безопасность', () => {
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
]);
renderApp('/settings/security');
renderApp('/settings/account/security');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Текущий пароль'), 'old-password-1');
@@ -152,7 +152,7 @@ describe('настройки: безопасность', () => {
},
]);
renderApp('/settings/profile');
renderApp('/settings/account/profile');
const visitor = userEvent.setup();
const displayName = await screen.findByLabelText('Отображаемое имя');
@@ -168,12 +168,55 @@ describe('настройки: безопасность', () => {
it('вкладка «Инстанс» скрыта от не-администратора', async () => {
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
renderApp('/settings/profile');
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([
@@ -220,7 +263,7 @@ describe('настройки: безопасность', () => {
},
]);
renderApp('/settings/instance');
renderApp('/settings/account/instance');
expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main');
expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice');
+90 -1
View File
@@ -115,13 +115,102 @@ describe('основной экран', () => {
const panel = screen.getByLabelText('Панель пользователя');
expect(within(panel).getByText('Alice')).toBeVisible();
expect(within(panel).getByLabelText('Настройки пользователя')).toBeVisible();
expect(within(panel).getByLabelText('Выйти')).toBeVisible();
// Выход переехал во всплывающее меню (проверяется в tests/userMenu.test.tsx).
expect(within(panel).getByLabelText('Открыть меню пользователя')).toBeVisible();
// Заглушка комнаты вместо чата Фазы 1: шапка комнаты + подсказка.
expect(await screen.findByText('Чат появится в Фазе 2')).toBeVisible();
expect(screen.getByRole('heading', { name: 'общий' })).toBeVisible();
});
it('анимационные классы не мешают рендеру оболочки', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]);
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-general');
await screen.findByTestId('guild-rail');
await gateway.greet();
// Каскад списков и анимация появления страницы — обычные CSS-классы.
expect(screen.getByTestId('guild-rail')).toHaveClass('gl-stagger');
expect(document.querySelector('.gl-page')).not.toBeNull();
await waitFor(() => {
expect(screen.getByLabelText('Открыть комнату общий')).toHaveClass('gl-indicator');
});
});
it('шестерёнка настроек сервера видна владельцу и ведёт в настройки', async () => {
const { default: userEvent } = await import('@testing-library/user-event');
const visitor = userEvent.setup();
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{
match: '/api/v1/guilds/g-1',
response: () =>
json({
guild: {
id: 'g-1',
name: 'Основной сервер',
description: 'главный',
owner_id: 'user-1',
is_main: true,
member_count: 1,
roles: [],
my_role_ids: [],
my_permissions: [],
},
}),
},
]);
const gateway = installGatewaySocket(snapshot);
const { router } = renderApp('/app/g-1/c-general');
await screen.findByTestId('guild-rail');
await gateway.greet();
const gear = await screen.findByLabelText('Настройки сервера');
await visitor.click(gear);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers/g-1');
});
});
it('обычный участник не видит шестерёнку настроек сервера', async () => {
const memberSnapshot = makeReadySnapshot([
{
id: 'g-9',
name: 'Чужой сервер',
owner_id: 'user-2',
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', position: 0 }],
},
]);
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/guilds/g-9/channels', response: () => json({ channels: [] }) },
]);
const gateway = installGatewaySocket(memberSnapshot);
renderApp('/app/g-9/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
await screen.findByLabelText('Открыть комнату общий');
expect(screen.queryByLabelText('Настройки сервера')).toBeNull();
expect(screen.queryByLabelText('Создать комнату')).toBeNull();
});
it('пустой список серверов предлагает главный сервер и создание своего', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { findRequest, installFetch, json, makeUser, recordedRequests, renderApp } from './helpers';
import type { FetchRoute } from './helpers';
const user = makeUser();
/** Базовые ответы для оболочки приложения без серверов. */
function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
return [
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
...extra,
];
}
/** Открывает нижне-левое меню пользователя кликом по аватару/имени. */
async function openMenu() {
const trigger = await screen.findByLabelText('Открыть меню пользователя');
await userEvent.click(trigger);
const menu = await screen.findByRole('menu', { name: 'Меню пользователя' });
return { trigger, menu };
}
describe('меню пользователя', () => {
it('открывается по клику на аватар и содержит все пункты', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const { menu } = await openMenu();
expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible();
expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible();
expect(
within(menu).getByRole('menuitemradio', { name: 'В сети', checked: true }),
).toBeVisible();
expect(within(menu).getByRole('menuitemradio', { name: 'Не беспокоить' })).toBeVisible();
expect(within(menu).getByRole('menuitem', { name: 'Выйти' })).toBeVisible();
});
it('шестерёнка открывает то же меню', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
expect(await screen.findByRole('menu', { name: 'Меню пользователя' })).toBeVisible();
});
it('пункт «Настройки аккаунта» ведёт в /settings/account/profile', async () => {
installFetch(baseRoutes());
const { router } = renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/account/profile');
});
expect(await screen.findByRole('heading', { name: 'Профиль' })).toBeVisible();
});
it('пункт «Настройки серверов» ведёт в /settings/servers', async () => {
installFetch(baseRoutes());
const { router } = renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers');
});
expect(await screen.findByText('У вас пока нет серверов.')).toBeVisible();
});
it('смена статуса уходит в PATCH /users/@me и отмечает текущий статус', async () => {
const fetchMock = installFetch(
baseRoutes([
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { ...user, status: 'invisible' } }),
},
]),
);
renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitemradio', { name: 'Невидимый' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({
status: 'invisible',
});
});
// Меню закрывается, а статус в панели обновляется из ответа сервера.
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull();
});
expect(screen.getByLabelText('Панель пользователя')).toHaveTextContent('Невидимый');
});
it('«Выйти» вызывает POST /auth/logout и уводит на /login', async () => {
const fetchMock = installFetch(
baseRoutes([
{ match: '/api/v1/auth/logout', method: 'POST', response: () => json({ ok: true }) },
]),
);
const { router } = renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Выйти' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/login');
});
expect(
recordedRequests(fetchMock).some(
(request) => request.url.includes('/auth/logout') && request.method === 'POST',
),
).toBe(true);
});
it('закрывается по Escape и возвращает фокус на кнопку', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const { trigger } = await openMenu();
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull();
});
expect(trigger).toHaveFocus();
});
it('закрывается по клику вне меню', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
await openMenu();
await userEvent.click(document.body);
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull();
});
});
it('доступно с клавиатуры: фокус на первом пункте и стрелки', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const trigger = await screen.findByLabelText('Открыть меню пользователя');
trigger.focus();
await userEvent.keyboard('{Enter}');
const first = await screen.findByRole('menuitem', { name: 'Настройки аккаунта' });
await waitFor(() => {
expect(first).toHaveFocus();
});
await userEvent.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: 'Настройки серверов' })).toHaveFocus();
await userEvent.keyboard('{ArrowUp}');
expect(screen.getByRole('menuitem', { name: 'Настройки аккаунта' })).toHaveFocus();
});
});