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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user