177 lines
6.0 KiB
TypeScript
177 lines
6.0 KiB
TypeScript
|
|
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,
|
||
|
|
};
|
||
|
|
}
|