Files
glchat/web/tests/helpers.tsx
T

177 lines
6.0 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 } },
});
}
/** Рендер приложения на заданном маршруте с изолированными сторами. */
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,
...overrides,
};
}
interface SnapshotGuildInput {
id: string;
name: string;
is_main?: boolean;
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: 'user-1',
is_main: guild.is_main ?? false,
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: [],
})),
dm_channels: [],
read_states: [],
session_id: 'session-1',
heartbeat_interval_ms: 45_000,
};
}