Files
glchat/web/tests/helpers.tsx
T

523 lines
19 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 { 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 { 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<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({ 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<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()),
);
}
/** Сброс всех сторов к исходному состоянию (изоляция между тестами). */
/**
* Сброс 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();
// Саундборд: подпись и очередь звуков не должны переживать тест.
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(
<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;
/** Оформление сервера (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;
}
/** Снапшот 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 }),
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<void>;
/** Событие 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<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
export function deferred<T>(): Deferred<T> {
let settle!: (value: T) => void;
const promise = new Promise<T>((resolve) => {
settle = resolve;
});
return { promise, resolve: (value) => settle(value) };
}
/** Сообщение для моков и тестов стора. */
export function makeMessage(
overrides: Partial<Message> & { 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 }),
},
];
}