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 } }, }); } /** Рендер приложения на заданном маршруте с изолированными сторами. */ 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, ...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, }; }