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 { GatewayOp } from '@/api/gateway'; import type { Message } from '@/api/messages'; import { routes } from '@/router'; import { setQueryClient } from '@/lib/queryClient'; import { useFriendsStore } from '@/stores/friends'; import { useGatewayStore } from '@/stores/gateway'; import { useMessagesStore } from '@/stores/messages'; import { useSessionStore } from '@/stores/session'; import { useSoundboardStore } from '@/stores/soundboard'; import { useUiStore } from '@/stores/ui'; import { useDmCallStore } from '@/stores/dmCall'; import { useVoiceStore } from '@/stores/voice'; import { useVoiceConnectionStore } from '@/stores/voiceConnection'; 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; /** Ответ на запрос: обработчик видит url, метод и разобранное тело. */ response: (request: RecordedRequest) => 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({ url, method, body: parseRequestBody(init?.body) })); } return Promise.resolve(apiError('not_found', 404)); }); vi.stubGlobal('fetch', fetchMock); return fetchMock; } /** Тело запроса как JSON: у multipart и пустого тела его нет. */ function parseRequestBody(body: BodyInit | null | undefined): unknown { if (typeof body !== 'string') { return undefined; } try { return JSON.parse(body) as unknown; } catch { return undefined; } } /** Мок 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()), ); } /** Сброс всех сторов к исходному состоянию (изоляция между тестами). */ /** * Сброс UI-стора к начальному состоянию: окно настроек закрыто, список друзей * свёрнут. Нужен и внутри `renderApp`, и в тестах, которые меняют настройки * вида без перерисовки приложения. */ export function resetUiStore(): void { useUiStore.setState({ guildCreateOpen: false, settingsOpen: false, settingsSection: 'profile', settingsProfileTab: 'main', friendsListOpen: false, }); } export function resetStores(): void { useSessionStore.getState().reset(); useMessagesStore.getState().reset(); useVoiceStore.getState().reset(); // Голосовая сессия могла остаться от прошлого теста: гасим её без запросов. useVoiceConnectionStore.getState().reset(); // Звонок в беседе — тоже сессия: комната LiveKit не должна переживать тест. useDmCallStore.getState().reset(); // Саундборд: подпись и очередь звуков не должны переживать тест. useSoundboardStore.getState().reset(); useFriendsStore.getState().reset(); resetUiStore(); // `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста // (переподключение, статус) меняли бы стор уже следующего. useGatewayStore.getState().reset(); } /** Рендер приложения на заданном маршруте с изолированными сторами. */ export function renderApp(initialPath = '/') { const queryClient = createTestQueryClient(); // Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша), // поэтому подменяем его на тестовый. setQueryClient(queryClient); resetStores(); 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; /** Оформление сервера (AGENT.md 7.4): баннер, splash и акцентный цвет. */ banner_file_id?: string; splash_file_id?: string; accent_color?: number; my_permissions?: string[]; my_nickname?: string; /** Кастомные эмодзи сервера (AGENT.md 7.12). */ emojis?: { id: string; name: string; file_id: string; animated?: boolean; token?: string }[]; /** Саундборд и звуковая палитра сервера (AGENT.md 7.13). */ sounds?: { id: string; name: string; file_id: string; kind?: 'soundboard' | 'ui'; event?: string; emoji?: string; }[]; /** Кто сидит в голосовых комнатах (AGENT.md 7.14). */ voice_states?: { user_id: string; channel_id: string; self_mute?: boolean; self_deaf?: boolean; server_mute?: boolean; server_deaf?: boolean; camera?: boolean; screen?: boolean; joined_at?: string; }[]; channels?: { id: string; name: string; type?: 'text' | 'voice' | 'category'; position?: number; parent_id?: string; can_send?: boolean; can_view?: boolean; /** Статус комнаты: описание в шапке рядом с названием. */ description?: string; /** Фон комнаты: файл-картинка за лентой (AGENT.md 7.5). */ background_file_id?: string; }[]; } /** Личная беседа в снапшоте READY. */ export interface SnapshotDmInput { id: string; /** Имя собеседника (сервер отдаёт display_name в поле `name`). */ name: string; recipient_id?: string; icon_file_id?: string; status?: string; last_message_id?: string; last_message_at?: string; can_send?: boolean; /** Идущий в беседе звонок (AGENT.md 7.8, Фаза 7): маркер «идёт звонок». */ active_call?: unknown; } /** Снапшот READY в формате шлюза. */ export function makeReadySnapshot( guilds: SnapshotGuildInput[] = [], dmChannels: SnapshotDmInput[] = [], ) { 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.banner_file_id === undefined ? {} : { banner_file_id: guild.banner_file_id }), ...(guild.splash_file_id === undefined ? {} : { splash_file_id: guild.splash_file_id }), ...(guild.accent_color === undefined ? {} : { accent_color: guild.accent_color }), ...(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 }), ...(channel.description === undefined ? {} : { description: channel.description }), ...(channel.background_file_id === undefined ? {} : { background_file_id: channel.background_file_id }), })), roles: [], member_ids: ['user-1'], my_role_ids: [], my_permissions: guild.my_permissions ?? [], emojis: (guild.emojis ?? []).map((emoji) => ({ id: emoji.id, name: emoji.name, file_id: emoji.file_id, animated: emoji.animated ?? false, token: emoji.token ?? `<:${emoji.name}:${emoji.file_id}>`, })), sounds: (guild.sounds ?? []).map((sound) => ({ id: sound.id, name: sound.name, file_id: sound.file_id, kind: sound.kind ?? 'soundboard', ...(sound.event === undefined ? {} : { event: sound.event }), ...(sound.emoji === undefined ? {} : { emoji: sound.emoji }), })), voice_states: (guild.voice_states ?? []).map((state, index) => ({ user_id: state.user_id, channel_id: state.channel_id, guild_id: guild.id, self_mute: state.self_mute ?? false, self_deaf: state.self_deaf ?? false, server_mute: state.server_mute ?? false, server_deaf: state.server_deaf ?? false, camera: state.camera ?? false, screen: state.screen ?? false, joined_at: state.joined_at ?? `2026-09-19T10:0${String(index)}:00Z`, })), })), dm_channels: dmChannels.map((channel) => ({ id: channel.id, type: 'dm', name: channel.name, position: 0, ...(channel.recipient_id === undefined ? {} : { recipient_id: channel.recipient_id }), ...(channel.icon_file_id === undefined ? {} : { icon_file_id: channel.icon_file_id }), ...(channel.status === undefined ? {} : { status: channel.status }), ...(channel.last_message_id === undefined ? {} : { last_message_id: channel.last_message_id }), ...(channel.last_message_at === undefined ? {} : { last_message_at: channel.last_message_at }), ...(channel.active_call === undefined ? {} : { active_call: channel.active_call }), can_send: channel.can_send ?? true, })), read_states: [], session_id: 'session-1', heartbeat_interval_ms: 45_000, }; } /* ------------------------------------------------------------------ */ /* Шлюз и сообщения Фазы 2 */ /* ------------------------------------------------------------------ */ /** Подставной WebSocket: рукопожатие и события под контролем теста. */ class FakeGatewaySocket { static instances: FakeGatewaySocket[] = []; readyState = 0; onopen: ((event: Event) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; onclose: ((event: CloseEvent) => void) | null = null; /** Кадры, отправленные клиентом (IDENTIFY, HEARTBEAT, RESUME). */ readonly sent: { op: number; d: unknown }[] = []; constructor(readonly url: string) { FakeGatewaySocket.instances.push(this); } send(raw: string): void { this.sent.push(JSON.parse(raw) as { op: number; d: unknown }); } close(): void { this.readyState = 3; } open(): void { this.readyState = 1; this.onopen?.(new Event('open')); } emit(packet: unknown): void { this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent); } dispatch(type: string, payload: unknown, seq = 1): void { this.emit({ op: GatewayOp.DISPATCH, t: type, s: seq, d: payload }); } } export interface GatewayHarness { /** Открывает сокет, отвечает HELLO и присылает READY со снапшотом. */ greet: () => Promise; /** Событие DISPATCH от сервера. */ dispatch: (type: string, payload: unknown) => void; /** Кадры, отправленные клиентом. */ sent: () => { op: number; d: unknown }[]; } /** * Подменяет WebSocket подставным и отдаёт управление рукопожатием. * Снапшот READY уходит после `greet()`: до этого момента шлюз «подключается». */ export function installGatewaySocket(snapshot: unknown): GatewayHarness { FakeGatewaySocket.instances = []; vi.stubGlobal('WebSocket', FakeGatewaySocket); const current = (): FakeGatewaySocket => { const socket = FakeGatewaySocket.instances[0]; if (socket === undefined) { throw new Error('шлюз не подключился'); } return socket; }; return { async greet() { await waitFor(() => { expect(FakeGatewaySocket.instances.length).toBeGreaterThan(0); }); const socket = current(); socket.open(); socket.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 45_000, session_id: 'session-1' }, }); socket.dispatch('READY', snapshot); }, dispatch(type, payload) { current().dispatch(type, payload); }, sent() { return current().sent; }, }; } /** Отложенный ответ: тест сам решает, когда сервер «ответит». */ export interface Deferred { promise: Promise; resolve: (value: T) => void; } export function deferred(): Deferred { let settle!: (value: T) => void; const promise = new Promise((resolve) => { settle = resolve; }); return { promise, resolve: (value) => settle(value) }; } /** Сообщение для моков и тестов стора. */ export function makeMessage( overrides: Partial & { id: string; channel_id: string }, ): Message { return { content: 'привет', type: 'default', pinned: false, attachments: [], mentions: [], reactions: [], created_at: '2026-09-19T10:00:00Z', ...overrides, }; } /** Мок `GET /channels/{id}/messages` и других ручек комнаты. */ export function messagesRoutes( channelId: string, messages: Message[], extra: FetchRoute[] = [], ): FetchRoute[] { // Дополнительные маршруты идут первыми: сортировка в installFetch устойчива, // поэтому при одинаковом шаблоне выигрывает маршрут теста. return [ ...extra, { match: `/api/v1/channels/${channelId}/messages/search`, response: () => json({ messages: [] }), }, { match: `/api/v1/channels/${channelId}/pins`, response: () => json({ messages: messages.filter((message) => message.pinned) }), }, { match: `/api/v1/channels/${channelId}/messages`, response: () => json({ messages: [...messages].reverse() }), }, { match: `/api/v1/channels/${channelId}/ack`, method: 'POST', response: () => json({ ok: true }), }, { match: `/api/v1/channels/${channelId}/typing`, method: 'POST', response: () => json({ ok: true }), }, ]; }