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; } /** * Мок 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): 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, 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( , ); 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 { return screen.findByLabelText(label); } /** Полный профиль пользователя для мока `GET /users/@me`. */ export function makeUser(overrides: Partial = {}): 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, }; }