0e39b8e027
- 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.
228 lines
7.9 KiB
TypeScript
228 lines
7.9 KiB
TypeScript
import { render, screen, waitFor } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { createMemoryRouter, RouterProvider } from 'react-router';
|
|
import { vi } from 'vitest';
|
|
|
|
import { routes } from '@/router';
|
|
import { setQueryClient } from '@/lib/queryClient';
|
|
import { useGatewayStore } from '@/stores/gateway';
|
|
import { useSessionStore } from '@/stores/session';
|
|
import type { User } from '@/api/types';
|
|
|
|
/** Ответ-заглушка с JSON-телом (как у настоящего API). */
|
|
export function json(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
/** Ошибка в формате контракта: 4xx + `{"error":{"code","message"}}`. */
|
|
export function apiError(code: string, status = 400, message = 'error'): Response {
|
|
return json({ error: { code, message } }, status);
|
|
}
|
|
|
|
export interface FetchRoute {
|
|
/** Подстрока URL, по которой выбирается обработчик. */
|
|
match: string;
|
|
method?: string;
|
|
response: () => Response | Promise<Response>;
|
|
}
|
|
|
|
/**
|
|
* Мок fetch по таблице маршрутов. Метод сравнивается строго (по умолчанию GET),
|
|
* URL — по вхождению подстроки. Более длинные шаблоны проверяются раньше,
|
|
* поэтому `/users/@me/guilds` не перехватывается записью `/users/@me`.
|
|
*/
|
|
export function installFetch(routes: FetchRoute[]) {
|
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input instanceof Request ? input.url : String(input);
|
|
const method = (
|
|
init?.method ?? (input instanceof Request ? input.method : 'GET')
|
|
).toUpperCase();
|
|
const ordered = [...routes].sort((left, right) => right.match.length - left.match.length);
|
|
for (const route of ordered) {
|
|
if ((route.method ?? 'GET').toUpperCase() !== method) {
|
|
continue;
|
|
}
|
|
if (!url.includes(route.match)) {
|
|
continue;
|
|
}
|
|
return Promise.resolve(route.response());
|
|
}
|
|
return Promise.resolve(apiError('not_found', 404));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
/** Мок fetch, который падает на уровне сети (сервер недоступен). */
|
|
export function installFailingFetch() {
|
|
const fetchMock = vi.fn(() => Promise.reject(new TypeError('Failed to fetch')));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
export function createTestQueryClient(): QueryClient {
|
|
return new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
}
|
|
|
|
/** URL запроса независимо от того, строка это или Request. */
|
|
export function requestUrl(input: RequestInfo | URL): string {
|
|
return input instanceof Request ? input.url : String(input);
|
|
}
|
|
|
|
export interface RecordedRequest {
|
|
url: string;
|
|
method: string;
|
|
body: unknown;
|
|
}
|
|
|
|
/** Последний (или отфильтрованный) вызов fetch в удобном виде. */
|
|
export function recordedRequests(fetchMock: ReturnType<typeof installFetch>): RecordedRequest[] {
|
|
return fetchMock.mock.calls.map(([input, init]) => {
|
|
const request = input instanceof Request ? input : undefined;
|
|
const raw = init?.body;
|
|
return {
|
|
url: requestUrl(input),
|
|
method: (init?.method ?? request?.method ?? 'GET').toUpperCase(),
|
|
body: typeof raw === 'string' ? (JSON.parse(raw) as unknown) : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Тело запроса как JSON-объект (или undefined, если тела не было). */
|
|
export function bodyOf(request: RecordedRequest | undefined): unknown {
|
|
return request?.body;
|
|
}
|
|
|
|
/** Первый записанный запрос, подходящий под фильтр. */
|
|
export function findRequest(
|
|
fetchMock: ReturnType<typeof installFetch>,
|
|
predicate: { url: string; method?: string },
|
|
): RecordedRequest | undefined {
|
|
return recordedRequests(fetchMock).find(
|
|
(request) =>
|
|
request.url.includes(predicate.url) &&
|
|
(predicate.method === undefined || request.method === predicate.method.toUpperCase()),
|
|
);
|
|
}
|
|
|
|
/** Рендер приложения на заданном маршруте с изолированными сторами. */
|
|
export function renderApp(initialPath = '/') {
|
|
const queryClient = createTestQueryClient();
|
|
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
|
|
// поэтому подменяем его на тестовый.
|
|
setQueryClient(queryClient);
|
|
useSessionStore.getState().reset();
|
|
useGatewayStore.setState({
|
|
status: 'idle',
|
|
heartbeatIntervalMs: null,
|
|
sessionId: null,
|
|
attempts: 0,
|
|
invalidated: false,
|
|
});
|
|
|
|
const router = createMemoryRouter(routes, { initialEntries: [initialPath] });
|
|
const view = render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>,
|
|
);
|
|
return { ...view, router, queryClient };
|
|
}
|
|
|
|
/**
|
|
* Ждёт, пока отрисуется ленивая страница: react-router подгружает её
|
|
* асинхронно, поэтому сразу после render DOM ещё пуст.
|
|
*/
|
|
export async function waitForPage(anchor = 'glchat') {
|
|
await waitFor(() => {
|
|
expect(document.body.textContent ?? '').toContain(anchor);
|
|
});
|
|
}
|
|
|
|
/** Ждёт появления поля по label и возвращает его. */
|
|
export async function findField(label: string): Promise<HTMLElement> {
|
|
return screen.findByLabelText(label);
|
|
}
|
|
|
|
/** Полный профиль пользователя для мока `GET /users/@me`. */
|
|
export function makeUser(overrides: Partial<User> = {}): User {
|
|
return {
|
|
id: 'user-1',
|
|
username: 'alice',
|
|
display_name: 'Alice',
|
|
bio: '',
|
|
status: 'online',
|
|
custom_status: '',
|
|
custom_status_emoji: '',
|
|
is_instance_admin: false,
|
|
badges: [],
|
|
locale: 'ru',
|
|
onboarding_completed: true,
|
|
totp_enabled: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
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;
|
|
type?: 'text' | 'voice' | 'category';
|
|
position?: number;
|
|
parent_id?: string;
|
|
can_send?: boolean;
|
|
can_view?: boolean;
|
|
}[];
|
|
}
|
|
|
|
/** Снапшот READY в формате шлюза. */
|
|
export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
|
|
return {
|
|
user: {
|
|
id: 'user-1',
|
|
username: 'alice',
|
|
display_name: 'Alice',
|
|
is_instance_admin: false,
|
|
badges: [],
|
|
},
|
|
guilds: guilds.map((guild) => ({
|
|
id: guild.id,
|
|
name: guild.name,
|
|
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,
|
|
type: channel.type ?? 'text',
|
|
position: channel.position ?? index,
|
|
...(channel.parent_id === undefined ? {} : { parent_id: channel.parent_id }),
|
|
...(channel.can_send === undefined ? {} : { can_send: channel.can_send }),
|
|
...(channel.can_view === undefined ? {} : { can_view: channel.can_view }),
|
|
})),
|
|
roles: [],
|
|
member_ids: ['user-1'],
|
|
my_role_ids: [],
|
|
my_permissions: guild.my_permissions ?? [],
|
|
})),
|
|
dm_channels: [],
|
|
read_states: [],
|
|
session_id: 'session-1',
|
|
heartbeat_interval_ms: 45_000,
|
|
};
|
|
}
|