Files
glchat/web/tests/shell.test.tsx
T
grendervill 0e39b8e027 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.
2026-09-19 23:36:16 +03:00

348 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { vi } from 'vitest';
import { GatewayOp } from '@/api/gateway';
import {
installFetch,
json,
makeReadySnapshot,
makeUser,
recordedRequests,
renderApp,
} from './helpers';
/** Подставной WebSocket: сразу отвечает HELLO и присылает READY. */
class FakeSocket {
static instances: FakeSocket[] = [];
readyState = 0;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(readonly url: string) {
FakeSocket.instances.push(this);
}
send(): void {
// IDENTIFY/HEARTBEAT от клиента в этом тесте не проверяются.
}
close(): void {
this.readyState = 3;
}
open(): void {
this.readyState = 1;
this.onopen?.(new Event('open'));
}
emit(packet: unknown): void {
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
}
}
function installGatewaySocket(snapshot: unknown) {
FakeSocket.instances = [];
vi.stubGlobal('WebSocket', FakeSocket);
return {
/** Ждёт, пока GatewayBridge откроет сокет, и проигрывает рукопожатие. */
async greet() {
await waitFor(() => {
expect(FakeSocket.instances.length).toBeGreaterThan(0);
});
const socket = FakeSocket.instances[0];
if (socket === undefined) {
throw new Error('шлюз не подключился');
}
socket.open();
socket.emit({
op: GatewayOp.HELLO,
d: { heartbeat_interval_ms: 45_000, session_id: 'session-1' },
});
socket.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: snapshot });
},
};
}
const user = makeUser();
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Основной сервер',
is_main: true,
channels: [
{ id: 'cat-1', name: 'Текстовые', type: 'category', position: 0 },
{ id: 'c-general', name: 'общий', position: 1, parent_id: 'cat-1' },
{ id: 'c-readonly', name: 'анонсы', position: 2, parent_id: 'cat-1', can_send: false },
{ id: 'c-voice', name: 'Голосовая', type: 'voice', position: 3 },
],
},
{ id: 'g-2', name: 'Второй сервер' },
]);
describe('основной экран', () => {
it('рендерит серверы и комнаты из снапшота READY', 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');
// Ждём, пока AuthGuard отдаст профиль и смонтируется оболочка.
await screen.findByTestId('guild-rail');
await gateway.greet();
// Серверы в рейке.
const rail = screen.getByTestId('guild-rail');
await waitFor(() => {
expect(within(rail).getAllByRole('link')).toHaveLength(2);
});
expect(within(rail).getByLabelText('Открыть сервер Второй сервер')).toBeVisible();
// Комнаты: категория, текстовая, «только чтение» (can_send=false) и голосовая.
expect(await screen.findByLabelText('Открыть комнату общий')).toBeVisible();
expect(screen.getByLabelText('Открыть комнату анонсы')).toBeVisible();
expect(screen.getByLabelText('Открыть комнату Голосовая')).toBeVisible();
expect(screen.getByRole('heading', { name: 'Текстовые' })).toBeVisible();
// Панель пользователя.
const panel = screen.getByLabelText('Панель пользователя');
expect(within(panel).getByText('Alice')).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 }) },
{
match: '/api/v1/instance',
response: () =>
json({
instance: {
name: 'glchat-test',
registration_enabled: true,
allow_guild_creation: true,
max_guilds_per_user: 5,
main_guild_id: 'g-main',
},
}),
},
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
]);
renderApp('/app/empty');
expect(
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
).toBeVisible();
expect(screen.getByLabelText('Создать сервер')).toBeVisible();
});
it('после вступления в главный сервер открывает его в URL', async () => {
const { default: userEvent } = await import('@testing-library/user-event');
const visitor = userEvent.setup();
const mainGuild = {
id: 'g-main',
name: 'Главный',
owner_id: 'user-1',
is_main: true,
member_count: 5,
my_role_ids: [],
my_permissions: [],
};
let joined = false;
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{
match: '/api/v1/instance',
response: () =>
json({
instance: {
name: 'glchat-test',
registration_enabled: true,
allow_guild_creation: true,
max_guilds_per_user: 5,
main_guild_id: 'g-main',
},
}),
},
{
match: '/api/v1/guilds/g-main/join',
method: 'POST',
response: () => {
joined = true;
return json({ ok: true });
},
},
{
match: '/api/v1/users/@me/guilds',
response: () => json({ guilds: joined ? [mainGuild] : [] }),
},
{
match: '/api/v1/guilds/g-main/channels',
response: () =>
json({ channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }] }),
},
]);
const { router } = renderApp('/app/empty');
await visitor.click(
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
);
// В снапшоте сервера может не быть — REST-подстраховка обновляет список,
// после чего пользователь попадает в первую комнату сервера.
await waitFor(() => {
expect(router.state.location.pathname).toStrictEqual(expect.stringContaining('/app/g-main'));
});
});
it('создаёт сервер через модальное окно', async () => {
const { default: userEvent } = await import('@testing-library/user-event');
const visitor = userEvent.setup();
const created = {
id: 'g-new',
name: 'Новый',
owner_id: 'user-1',
is_main: false,
member_count: 1,
my_role_ids: [],
my_permissions: [],
};
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{
match: '/api/v1/instance',
response: () =>
json({
instance: {
name: 'glchat-test',
registration_enabled: true,
allow_guild_creation: true,
max_guilds_per_user: 5,
},
}),
},
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/guilds', method: 'POST', response: () => json({ guild: created }) },
{ match: '/api/v1/guilds/g-new/channels', response: () => json({ channels: [] }) },
{ match: '/api/v1/guilds/g-new', response: () => json({ guild: { ...created, roles: [] } }) },
]);
const { router } = renderApp('/app/empty');
await visitor.click(await screen.findByLabelText('Создать сервер'));
await visitor.type(await screen.findByLabelText('Название сервера'), 'Новый');
await visitor.click(screen.getByRole('button', { name: 'Создать' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/g-new');
});
const createCall = recordedRequests(fetchMock).find(
(request) => request.url.endsWith('/api/v1/guilds') && request.method === 'POST',
);
expect(createCall?.body).toEqual({ name: 'Новый' });
});
});