Files
glchat/web/tests/helpers.tsx
T

228 lines
7.9 KiB
TypeScript
Raw Normal View History

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,
};
}