feat(web): базовый клиент Фазы 1 — вход, серверы, комнаты, настройки

Клиент React 19 + TanStack Query + Zustand:

- вход (поле TOTP появляется на `auth.2fa_required`), регистрация с проверками
  и учётом `registration_enabled`, онбординг первого входа с возможностью
  пропустить, редирект `/` и страница `/status`;
- защита маршрутов: неавторизованных — на `/login`, без онбординга — на
  `/onboarding`;
- оболочка `/app`: рейка серверов, сайдбар категорий и комнат, шапка сервера,
  панель пользователя, модалки создания сервера и комнаты, экран «нет серверов»
  со вступлением в главный сервер, заглушка комнаты до Фазы 2;
- настройки: профиль, безопасность (step-up, сессии, logout-all, 2FA с
  локальным QR и кодами восстановления), внешний вид, админ-раздел инстанса;
- Gateway-клиент: HELLO/IDENTIFY/RESUME, heartbeat с ожиданием ACK,
  экспоненциальное переподключение, разбор `INVALID_SESSION {reason,resumable}`;
- диспетчер событий: READY/RESUMED/USER_UPDATE/GUILD_UPDATE/GUILD_DELETE/
  CHANNEL_* применяются к стору, GUILD_CREATE догружается по REST,
  MEMBER_*/ROLE_* инвалидируют запросы участников, ролей и карточки сервера;
- i18n ru/en, 66 тестов Vitest, `check`/`lint`/`test`/`build` зелёные.

Серверные правки под клиент:

- `totp_enabled` в профиле (`GET /users/@me`, вход, регистрация) — клиенту
  нужно знать, требовать ли код при step-up;
- `POST /auth/2fa/setup` отдаёт `otpauth_url` и `qr_png` (data-URI): QR-код
  рисуется локально, внешние сервисы генерации QR не используются;
- права в ответах — имена (`VIEW_CHANNEL|SEND_MESSAGES`), клиент сверяет имена.
This commit is contained in:
2026-09-19 22:03:46 +03:00
parent d6ca8e420f
commit 9c001fedfe
28 changed files with 1827 additions and 217 deletions
+179
View File
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import { enableTotp, login, logoutAll, register, setupTotp, stepUp } from '@/api/auth';
import { createChannel, fetchChannels, joinGuild, leaveGuild } from '@/api/guilds';
import { fetchInstance, fetchAudit } from '@/api/instance';
import { fetchMyGuilds, updateProfile } from '@/api/users';
import { ApiError } from '@/api/client';
import { findRequest, installFetch, json, recordedRequests } from './helpers';
function lastCall(fetchMock: ReturnType<typeof installFetch>) {
const request = recordedRequests(fetchMock).at(-1);
if (request === undefined) {
throw new Error('fetch не вызывался');
}
return request;
}
describe('api-модули Фазы 1', () => {
it('register/login отправляют POST с телом и читают поле user', async () => {
const fetchMock = installFetch([
{
match: '/api/v1/auth/register',
method: 'POST',
response: () => json({ user: { id: 'u' } }),
},
]);
await expect(
register({
username: 'alice',
display_name: 'Alice',
email: 'a@b.co',
password: 'password12',
locale: 'ru',
}),
).resolves.toEqual({ user: { id: 'u' } });
expect(findRequest(fetchMock, { url: '/auth/register', method: 'POST' })?.method).toBe('POST');
});
it('login отправляет POST с телом', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user: { id: 'u' } }) },
]);
await login({ email: 'a@b.co', password: 'password12' });
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
email: 'a@b.co',
password: 'password12',
});
});
it('login прокидывает код 2FA как totp_code', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user: { id: 'u' } }) },
]);
await login({ email: 'a@b.co', password: 'password12', totp_code: '123456' });
expect(lastCall(fetchMock).body).toEqual({
email: 'a@b.co',
password: 'password12',
totp_code: '123456',
});
});
it('setup/enable 2FA и step-up используют нужные пути', async () => {
const fetchMock = installFetch([
{
match: '/api/v1/auth/2fa/setup',
method: 'POST',
response: () => json({ secret: 'SECRET', otpauth_url: 'otpauth://x' }),
},
{
match: '/api/v1/auth/2fa/enable',
method: 'POST',
response: () => json({ recovery_codes: ['one', 'two'] }),
},
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
{ match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) },
]);
await expect(setupTotp()).resolves.toEqual({ secret: 'SECRET', otpauth_url: 'otpauth://x' });
await expect(enableTotp('123456')).resolves.toEqual(['one', 'two']);
await stepUp('password12', '654321');
expect(lastCall(fetchMock).body).toEqual({
password: 'password12',
totp_code: '654321',
});
await expect(logoutAll()).resolves.toEqual({ ok: true });
});
it('PATCH профиля уходит на /users/@me и возвращает user', async () => {
const fetchMock = installFetch([
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { id: 'u', display_name: 'Новое' } }),
},
]);
await expect(updateProfile({ display_name: 'Новое' })).resolves.toMatchObject({
display_name: 'Новое',
});
expect(lastCall(fetchMock).method).toBe('PATCH');
});
it('GET /users/@me/guilds разворачивает конверт {guilds}', async () => {
installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [{ id: 'g-1' }] }) },
]);
await expect(fetchMyGuilds()).resolves.toEqual([{ id: 'g-1' }]);
});
it('каналы и участие в сервере используют id в пути', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [{ id: 'c-1' }] }) },
{ match: '/api/v1/guilds/g-1/join', method: 'POST', response: () => json({ ok: true }) },
{ match: '/api/v1/guilds/g-1/leave', method: 'POST', response: () => json({ ok: true }) },
]);
await expect(fetchChannels('g-1')).resolves.toEqual([{ id: 'c-1' }]);
await expect(joinGuild('g-1')).resolves.toEqual({ ok: true });
await expect(leaveGuild('g-1')).resolves.toEqual({ ok: true });
expect(lastCall(fetchMock).url).toContain('/api/v1/guilds/g-1/leave');
});
it('createChannel отправляет type и parent_id', async () => {
const fetchMock = installFetch([
{
match: '/api/v1/guilds/g-1/channels',
method: 'POST',
response: () => json({ channel: { id: 'c-9' } }),
},
]);
await createChannel('g-1', { name: 'общий', type: 'text', parent_id: 'cat-1' });
expect(lastCall(fetchMock).body).toEqual({
name: 'общий',
type: 'text',
parent_id: 'cat-1',
});
});
it('instance: конверты {instance}, {settings}, {entries} и лимит аудита', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/instance/settings', response: () => json({ settings: { motd: 'x' } }) },
{ match: '/api/v1/instance/audit', response: () => json({ entries: [{ id: 'a-1' }] }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat' } }) },
]);
await expect(fetchInstance()).resolves.toEqual({ name: 'glchat' });
await expect(fetchAudit(50)).resolves.toEqual([{ id: 'a-1' }]);
expect(recordedRequests(fetchMock).map((request) => request.url)).toContain(
'/api/v1/instance/audit?limit=50',
);
});
it('пробрасывает коды ошибок контракта (в том числе auth.guild_limit_reached)', async () => {
installFetch([
{
match: '/api/v1/guilds',
method: 'POST',
response: () =>
json({ error: { code: 'auth.guild_limit_reached', message: 'limit' } }, 403),
},
]);
const error = await import('@/api/guilds')
.then(({ createGuild }) => createGuild('Много'))
.catch((reason: unknown) => reason);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).code).toBe('auth.guild_limit_reached');
expect((error as ApiError).status).toBe(403);
});
});
+24 -43
View File
@@ -1,10 +1,8 @@
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { App } from '@/App';
import { installFetch, installFailingFetch, json, renderApp } from './helpers';
import type { InstanceMeta } from '@/api/meta';
import '@/i18n';
const meta: InstanceMeta = {
name: 'glchat',
@@ -22,42 +20,22 @@ const meta: InstanceMeta = {
anti_bot_enabled: false,
voice_enabled: false,
web_push_enabled: false,
oauth_enabled: false,
passkeys_enabled: false,
oauth_enabled: false,
},
};
const readyBody = { status: 'ready', version: 'test', checks: { database: 'ok' } };
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
/** Страница состояния — бывший лендинг Фазы 0, перенесён на маршрут /status. */
describe('страница /status', () => {
it('рендерит мету инстанса из API', async () => {
installFetch([
{ match: '/api/v1/meta', response: () => json(meta) },
{ match: '/api/v1/readyz', response: () => json(readyBody) },
]);
function renderApp() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
}
describe('App', () => {
it('renders instance metadata from the API', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
return Promise.resolve(url.endsWith('/api/v1/meta') ? json(meta) : json(readyBody));
}),
);
renderApp();
renderApp('/status');
await waitFor(() => {
expect(screen.getByText('v0.1.0-test')).toBeInTheDocument();
@@ -67,13 +45,16 @@ describe('App', () => {
expect(screen.getByTestId('connection-state')).toHaveTextContent('Сервер доступен');
});
it('localizes a server error by its code and offers a retry', async () => {
const fetchMock = vi.fn(() =>
json({ error: { code: 'internal.error', message: 'boom' } }, 500),
);
vi.stubGlobal('fetch', fetchMock);
it('локализует ошибку сервера по коду и предлагает повтор', async () => {
installFetch([
{
match: '/api/v1/meta',
response: () => json({ error: { code: 'internal.error', message: 'boom' } }, 500),
},
{ match: '/api/v1/readyz', response: () => json(readyBody) },
]);
renderApp();
renderApp('/status');
await waitFor(
() => {
@@ -84,10 +65,10 @@ describe('App', () => {
expect(screen.getByRole('button', { name: 'Повторить' })).toBeInTheDocument();
});
it('falls back to a network message when the server is unreachable', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
it('показывает сообщение о недоступном сервере', async () => {
installFailingFetch();
renderApp();
renderApp('/status');
await waitFor(
() => {
+63 -16
View File
@@ -59,6 +59,16 @@ class FakeSocket {
}
}
const connections: GatewayConnection[] = [];
afterEach(() => {
// Закрываем соединения, иначе таймеры heartbeat продолжают жить между тестами.
for (const connection of connections.splice(0)) {
connection.close();
}
FakeSocket.instances = [];
});
function makeConnection(handlers: {
onDispatch?: (event: GatewayDispatch) => void;
onStatus?: (status: GatewayStatus) => void;
@@ -79,16 +89,6 @@ function makeConnection(handlers: {
return connection;
}
const connections: GatewayConnection[] = [];
afterEach(() => {
// Закрываем соединения, иначе таймеры heartbeat продолжают жить между тестами.
for (const connection of connections.splice(0)) {
connection.close();
}
FakeSocket.instances = [];
});
describe('gateway client', () => {
it('answers HELLO with IDENTIFY and an empty token', () => {
const connection = makeConnection({});
@@ -112,9 +112,14 @@ describe('gateway client', () => {
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: makeReadySnapshot([
{ id: 'g-1', name: 'Main', channels: [{ id: 'c-1', name: 'general' }] },
]) });
socket?.emit({
op: GatewayOp.DISPATCH,
t: 'READY',
s: 1,
d: makeReadySnapshot([
{ id: 'g-1', name: 'Main', channels: [{ id: 'c-1', name: 'general' }] },
]),
});
expect(events).toHaveLength(1);
expect(events[0]?.t).toBe('READY');
@@ -165,7 +170,7 @@ describe('gateway client', () => {
first?.closeFromServer();
expect(connection.getStatus()).toBe('reconnecting');
await new Promise((resolve) => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 100));
const second = FakeSocket.instances[1];
expect(second).toBeDefined();
second?.open();
@@ -176,16 +181,58 @@ describe('gateway client', () => {
connection.close();
});
it('reports INVALID_SESSION to the handler', () => {
it('INVALID_SESSION с resumable=false разлогинивает', () => {
let invalidated = 0;
const connection = makeConnection({ onInvalidSession: () => (invalidated += 1) });
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
socket?.emit({ op: GatewayOp.INVALID_SESSION, d: false });
socket?.emit({
op: GatewayOp.INVALID_SESSION,
d: { reason: 'invalid token', resumable: false },
});
expect(invalidated).toBe(1);
expect(connection.getStatus()).toBe('disconnected');
connection.close();
});
it('INVALID_SESSION с resumable=true не разлогинивает и ждёт полный READY', () => {
const events: GatewayDispatch[] = [];
let invalidated = 0;
const connection = makeConnection({
onDispatch: (event) => events.push(event),
onInvalidSession: () => (invalidated += 1),
});
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 3, d: makeReadySnapshot([]) });
socket?.emit({
op: GatewayOp.INVALID_SESSION,
d: { reason: 'resume missed', resumable: true },
});
socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 4, d: makeReadySnapshot([]) });
expect(invalidated).toBe(0);
expect(events.map((event) => event.t)).toEqual(['READY', 'READY']);
expect(connection.getLastSeq()).toBe(4);
expect(connection.getStatus()).toBe('connected');
connection.close();
});
it('op 7 (RECONNECT) переподключает сокет', async () => {
const connection = makeConnection({});
const first = FakeSocket.instances[0];
first?.open();
first?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
first?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 5, d: makeReadySnapshot([]) });
first?.emit({ op: GatewayOp.RECONNECT, d: null });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(FakeSocket.instances.length).toBe(2);
expect(connection.getStatus()).toBe('reconnecting');
connection.close();
});
});
+312
View File
@@ -0,0 +1,312 @@
import { afterEach, describe, expect, it } from 'vitest';
import { waitFor } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { GatewayOp, parseGatewaySnapshot } from '@/api/gateway';
import { guildMembersQueryKey, guildQueryKey, guildRolesQueryKey } from '@/api/guilds';
import { setQueryClient } from '@/lib/queryClient';
import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway';
import { useSessionStore } from '@/stores/session';
import { installFetch, json, makeReadySnapshot, recordedRequests } from './helpers';
/** Наполняет стор снапшотом READY с двумя серверами и комнатами. */
function seedSession() {
const snapshot = parseGatewaySnapshot(
makeReadySnapshot([
{
id: 'g-1',
name: 'Первый',
channels: [
{ id: 'c-1', name: 'общий', position: 0 },
{ id: 'c-2', name: 'флудилка', position: 1 },
],
},
{
id: 'g-2',
name: 'Второй',
channels: [{ id: 'c-9', name: 'болталка', position: 0 }],
},
]),
);
if (snapshot === null) {
throw new Error('снапшот не разобрался');
}
useSessionStore.getState().applyReady(snapshot);
}
function makeRecordingClient(): { client: QueryClient; invalidated: string[][] } {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidated: string[][] = [];
const original = client.invalidateQueries.bind(client);
client.invalidateQueries = (filters?: { queryKey?: readonly unknown[] }) => {
invalidated.push((filters?.queryKey ?? []).map((part) => String(part)));
return original(filters);
};
setQueryClient(client);
return { client, invalidated };
}
afterEach(() => {
useSessionStore.getState().reset();
useGatewayStore.setState({
status: 'idle',
heartbeatIntervalMs: null,
sessionId: null,
attempts: 0,
invalidated: false,
});
setQueryClient(null);
});
describe('диспетчер событий шлюза', () => {
it('USER_UPDATE обновляет профиль текущего пользователя', () => {
seedSession();
expect(useSessionStore.getState().user?.display_name).toBe('Alice');
dispatchGatewayEvent({
op: 0,
t: 'USER_UPDATE',
s: 2,
d: {
user: {
id: 'user-1',
username: 'alice',
display_name: 'Алиса',
is_instance_admin: false,
badges: ['early'],
avatar_file_id: 'file-7',
custom_status: 'Пишу код',
},
},
});
const user = useSessionStore.getState().user;
expect(user?.display_name).toBe('Алиса');
expect(user?.avatar_file_id).toBe('file-7');
expect(user?.custom_status).toBe('Пишу код');
expect(user?.badges).toEqual(['early']);
});
it('USER_UPDATE про другого пользователя ничего не меняет', () => {
seedSession();
dispatchGatewayEvent({
op: 0,
t: 'USER_UPDATE',
s: 3,
d: { user: { id: 'user-2', username: 'bob', display_name: 'Bob' } },
});
expect(useSessionStore.getState().user?.display_name).toBe('Alice');
});
it('GUILD_UPDATE меняет имя сервера', () => {
seedSession();
dispatchGatewayEvent({
op: 0,
t: 'GUILD_UPDATE',
s: 4,
d: { guild_id: 'g-2', name: 'Второй (обновлён)' },
});
expect(useSessionStore.getState().guilds.find((g) => g.id === 'g-2')?.name).toBe(
'Второй (обновлён)',
);
});
it('GUILD_DELETE удаляет сервер и выбирает первый оставшийся с комнатой', () => {
seedSession();
useSessionStore.getState().selectChannel('g-2', 'c-9');
dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 5, d: { guild_id: 'g-2' } });
const state = useSessionStore.getState();
expect(state.guilds.map((guild) => guild.id)).toEqual(['g-1']);
expect(state.selectedGuildId).toBe('g-1');
expect(state.selectedChannelId).toBe('c-1');
});
it('GUILD_DELETE последнего сервера очищает выбор', () => {
seedSession();
useSessionStore.getState().selectChannel('g-2', 'c-9');
dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 6, d: { guild_id: 'g-1' } });
dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 7, d: { guild_id: 'g-2' } });
const state = useSessionStore.getState();
expect(state.guilds).toEqual([]);
expect(state.selectedGuildId).toBeNull();
expect(state.selectedChannelId).toBeNull();
});
it('CHANNEL_CREATE добавляет комнату, CHANNEL_UPDATE заменяет её', () => {
seedSession();
dispatchGatewayEvent({
op: 0,
t: 'CHANNEL_CREATE',
s: 8,
d: {
id: 'c-3',
guild_id: 'g-1',
name: 'анонсы',
type: 'text',
position: 2,
can_send: false,
},
});
const created = useSessionStore
.getState()
.guilds.find((guild) => guild.id === 'g-1')
?.channels.find((channel) => channel.id === 'c-3');
expect(created?.name).toBe('анонсы');
expect(created?.can_send).toBe(false);
dispatchGatewayEvent({
op: 0,
t: 'CHANNEL_UPDATE',
s: 9,
d: { id: 'c-3', guild_id: 'g-1', name: 'новости', type: 'text', position: 2 },
});
const updated = useSessionStore
.getState()
.guilds.find((guild) => guild.id === 'g-1')
?.channels.filter((channel) => channel.id === 'c-3');
expect(updated).toHaveLength(1);
expect(updated?.[0]?.name).toBe('новости');
});
it('CHANNEL_DELETE удаляет комнату и переносит выбор на первую доступную', () => {
seedSession();
useSessionStore.getState().selectChannel('g-1', 'c-1');
dispatchGatewayEvent({
op: 0,
t: 'CHANNEL_DELETE',
s: 10,
d: { channel_id: 'c-1', guild_id: 'g-1' },
});
const state = useSessionStore.getState();
expect(
state.guilds.find((guild) => guild.id === 'g-1')?.channels.map((channel) => channel.id),
).toEqual(['c-2']);
expect(state.selectedChannelId).toBe('c-2');
});
it('MEMBER_UPDATE с ролями инвалидирует участников, роли и карточку сервера', async () => {
const { client, invalidated } = makeRecordingClient();
client.setQueryData(guildMembersQueryKey('g-1'), []);
client.setQueryData(guildRolesQueryKey('g-1'), []);
client.setQueryData(guildQueryKey('g-1'), {});
dispatchGatewayEvent({
op: 0,
t: 'MEMBER_UPDATE',
s: 11,
d: {
guild_id: 'g-1',
user_id: 'user-2',
role_ids: ['role-3'],
role_id: 'role-3',
role_added: true,
},
});
await waitFor(() => {
expect(invalidated.flat()).toContain('members');
});
expect(client.getQueryState(guildMembersQueryKey('g-1'))?.isInvalidated).toBe(true);
expect(client.getQueryState(guildRolesQueryKey('g-1'))?.isInvalidated).toBe(true);
expect(client.getQueryState(guildQueryKey('g-1'))?.isInvalidated).toBe(true);
expect(invalidated.map((key) => key.join('/'))).toEqual(
expect.arrayContaining(['guilds/g-1/members', 'guilds/g-1/roles', 'guilds/g-1']),
);
});
it('ROLE_DELETE инвалидирует данные сервера', async () => {
const { client, invalidated } = makeRecordingClient();
client.setQueryData(guildMembersQueryKey('g-1'), []);
dispatchGatewayEvent({
op: 0,
t: 'ROLE_DELETE',
s: 12,
d: { guild_id: 'g-1', role_id: 'role-3' },
});
await waitFor(() => {
expect(client.getQueryState(guildMembersQueryKey('g-1'))?.isInvalidated).toBe(true);
});
expect(invalidated.map((key) => key.join('/'))).toContain('guilds/g-1/roles');
});
it('MEMBER_REMOVE инвалидирует список участников', async () => {
const { client } = makeRecordingClient();
client.setQueryData(guildMembersQueryKey('g-1'), []);
dispatchGatewayEvent({
op: 0,
t: 'MEMBER_REMOVE',
s: 13,
d: { guild_id: 'g-1', user_id: 'user-2' },
});
await waitFor(() => {
expect(client.getQueryState(guildMembersQueryKey('g-1'))?.isInvalidated).toBe(true);
});
});
it('GUILD_CREATE подтягивает сервер из REST и кладёт его в стор', async () => {
const { client } = makeRecordingClient();
const guild = {
id: 'g-3',
name: 'Новый',
owner_id: 'user-1',
is_main: false,
member_count: 1,
my_role_ids: [],
my_permissions: [],
};
// В событии только guild_id — клиент обязан сходить за сводкой в REST.
const fetchMock = installFetch([
{
match: '/api/v1/users/@me/guilds',
response: () => json({ guilds: [guild] }),
},
]);
void client;
dispatchGatewayEvent({ op: 0, t: 'GUILD_CREATE', s: 14, d: { guild_id: 'g-3' } });
await waitFor(() => {
expect(useSessionStore.getState().guilds.some((item) => item.id === 'g-3')).toBe(true);
});
expect(useSessionStore.getState().guilds.find((item) => item.id === 'g-3')?.name).toBe('Новый');
expect(
recordedRequests(fetchMock).some((request) => request.url.includes('/users/@me/guilds')),
).toBe(true);
});
it('неизвестные и повреждённые события не ломают диспетчер', () => {
seedSession();
dispatchGatewayEvent({ op: 0, t: 'SOMETHING_NEW', s: 15, d: { x: 1 } });
dispatchGatewayEvent({ op: 0, t: 'CHANNEL_CREATE', s: 16, d: { name: 'без id' } });
dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 17, d: null });
dispatchGatewayEvent({ op: 0, t: 'USER_UPDATE', s: 18, d: 'мусор' });
expect(useSessionStore.getState().guilds).toHaveLength(2);
expect(useSessionStore.getState().user?.display_name).toBe('Alice');
});
it('оп-коды соответствуют AGENT.md §8.3', () => {
expect(GatewayOp.INVALID_SESSION).toBe(4);
expect(GatewayOp.RECONNECT).toBe(7);
expect(GatewayOp.HELLO).toBe(10);
expect(GatewayOp.HEARTBEAT_ACK).toBe(11);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { apiError, installFetch, installFailingFetch, json, makeUser, renderApp } from './helpers';
describe('защита маршрутов', () => {
it('неавторизованного на /app отправляет на /login', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => apiError('auth.unauthorized', 401) },
]);
const { router } = renderApp('/app');
await waitFor(() => {
expect(router.state.location.pathname).toBe('/login');
});
expect(await screen.findByLabelText('Почта')).toBeVisible();
});
it('пользователя с onboarding_completed=false отправляет на /onboarding', async () => {
const user = makeUser({ onboarding_completed: false });
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
const { router } = renderApp('/app');
await waitFor(() => {
expect(router.state.location.pathname).toBe('/onboarding');
});
expect(await screen.findByText('Привет, Alice!')).toBeVisible();
});
it('онбординг доступен при незавершённой настройке (без цикла редиректов)', async () => {
const user = makeUser({ onboarding_completed: false });
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{
match: '/api/v1/users/@me/onboarding/complete',
method: 'POST',
response: () => json({ user }),
},
]);
const { router } = renderApp('/onboarding');
expect(await screen.findByLabelText('Как вас показывать?')).toBeVisible();
expect(router.state.location.pathname).toBe('/onboarding');
});
it('/ без сессии ведёт на /login, а /status доступен без авторизации', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => apiError('auth.unauthorized', 401) },
{ match: '/api/v1/meta', response: () => apiError('internal.error', 500) },
{ match: '/api/v1/readyz', response: () => apiError('internal.error', 500) },
]);
const { router } = renderApp('/');
await waitFor(() => {
expect(router.state.location.pathname).toBe('/login');
});
const status = renderApp('/status');
expect(await status.findByTestId('connection-state')).toHaveTextContent('Сервер недоступен');
});
it('сетевую ошибку показывает как сообщение, а не редирект', async () => {
installFailingFetch();
const { router } = renderApp('/app');
expect(await screen.findByRole('alert')).toHaveTextContent('Сервер не отвечает');
expect(router.state.location.pathname).toBe('/app');
});
});
+45 -1
View File
@@ -37,7 +37,9 @@ export interface FetchRoute {
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 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) {
@@ -67,6 +69,47 @@ export function createTestQueryClient(): QueryClient {
});
}
/** 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()),
);
}
/** Рендер приложения на заданном маршруте с изолированными сторами. */
export function renderApp(initialPath = '/') {
const queryClient = createTestQueryClient();
@@ -120,6 +163,7 @@ export function makeUser(overrides: Partial<User> = {}): User {
badges: [],
locale: 'ru',
onboarding_completed: true,
totp_enabled: false,
...overrides,
};
}
+15 -12
View File
@@ -2,7 +2,15 @@ import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { apiError, installFetch, json, makeUser, renderApp } from './helpers';
import {
apiError,
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
} from './helpers';
const instanceOpen = {
name: 'glchat-test',
@@ -42,12 +50,7 @@ describe('страница входа', () => {
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
).toBeVisible();
const loginCall = fetchMock.mock.calls.find(([input]) =>
String(input).includes('/auth/login'),
);
expect(loginCall).toBeDefined();
const init = loginCall?.[1] as RequestInit;
expect(JSON.parse(String(init.body))).toEqual({
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
});
@@ -87,11 +90,11 @@ describe('страница входа', () => {
expect(router.state.location.pathname).toBe('/app/empty');
});
const secondLogin = fetchMock.mock.calls.filter(([input]) =>
String(input).includes('/auth/login'),
)[1];
const init = secondLogin?.[1] as RequestInit;
expect(JSON.parse(String(init.body))).toEqual({
const logins = recordedRequests(fetchMock).filter(
(request) => request.url.includes('/auth/login') && request.method === 'POST',
);
expect(logins).toHaveLength(2);
expect(logins[1]?.body).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
totp_code: '123456',
+3 -7
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { installFetch, json, makeUser, renderApp } from './helpers';
import { findRequest, installFetch, json, makeUser, recordedRequests, renderApp } from './helpers';
const instanceBase = {
name: 'glchat-test',
@@ -41,11 +41,7 @@ describe('страница регистрации', () => {
});
expect(await screen.findByText('Привет, Alice!')).toBeVisible();
const registerCall = fetchMock.mock.calls.find(([input]) =>
String(input).includes('/auth/register'),
);
const init = registerCall?.[1] as RequestInit;
expect(JSON.parse(String(init.body))).toEqual({
expect(findRequest(fetchMock, { url: '/auth/register', method: 'POST' })?.body).toEqual({
username: 'alice',
display_name: 'Алиса',
email: 'alice@example.com',
@@ -75,7 +71,7 @@ describe('страница регистрации', () => {
expect(screen.getByText('Укажите корректный адрес почты.')).toBeVisible();
expect(screen.getByText('Пароль должен быть не короче 8 символов.')).toBeVisible();
expect(
fetchMock.mock.calls.filter(([input]) => String(input).includes('/auth/register')),
recordedRequests(fetchMock).filter((request) => request.url.includes('/auth/register')),
).toHaveLength(0);
});
+16 -11
View File
@@ -35,17 +35,22 @@ describe('session store', () => {
expect(snapshot).not.toBeNull();
act(() => {
dispatchGatewayEvent({ op: 0, t: 'READY', s: 1, d: makeReadySnapshot([
{
id: 'g-1',
name: 'Main',
is_main: true,
channels: [
{ id: 'c-1', name: 'general', position: 0 },
{ id: 'c-2', name: 'voice', type: 'voice', position: 1 },
],
},
]) });
dispatchGatewayEvent({
op: 0,
t: 'READY',
s: 1,
d: makeReadySnapshot([
{
id: 'g-1',
name: 'Main',
is_main: true,
channels: [
{ id: 'c-1', name: 'general', position: 0 },
{ id: 'c-2', name: 'voice', type: 'voice', position: 1 },
],
},
]),
});
});
const state = useSessionStore.getState();
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
apiError,
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
} from './helpers';
const user = makeUser();
const sessions = [
{
id: 's-1',
user_agent: 'Firefox on Linux',
ip: '10.0.0.2',
created_at: '2026-09-01T10:00:00Z',
last_seen: '2026-09-19T10:00:00Z',
current: true,
},
{
id: 's-2',
user_agent: 'Chrome on macOS',
ip: '10.0.0.3',
created_at: '2026-09-02T10:00:00Z',
last_seen: '2026-09-18T10:00:00Z',
current: false,
},
];
describe('настройки: безопасность', () => {
it('включение 2FA показывает секрет и коды восстановления', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
{
match: '/api/v1/auth/2fa/setup',
method: 'POST',
response: () =>
json({
secret: 'JBSWY3DPEHPK3PXP',
otpauth_url: 'otpauth://totp/glchat:alice?secret=X',
// QR-код рисует сервер и отдаёт data-URI: внешние сервисы не нужны.
qr_png: 'data:image/png;base64,iVBORw0KGgo=',
}),
},
{
match: '/api/v1/auth/2fa/enable',
method: 'POST',
response: () => json({ recovery_codes: ['aaaa-bbbb', 'cccc-dddd'] }),
},
]);
renderApp('/settings/security');
const visitor = userEvent.setup();
await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' }));
expect(await screen.findByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP');
expect(screen.getByAltText('QR-код для настройки 2FA')).toBeVisible();
await visitor.type(screen.getByLabelText('Код из приложения'), '123456');
await visitor.click(screen.getByRole('button', { name: 'Включить' }));
const codes = await screen.findByTestId('recovery-codes');
expect(codes).toHaveTextContent('aaaa-bbbb');
expect(codes).toHaveTextContent('cccc-dddd');
expect(screen.getByText('2FA включена. Сохраните коды восстановления.')).toBeVisible();
expect(findRequest(fetchMock, { url: '/auth/2fa/enable', method: 'POST' })?.body).toEqual({
code: '123456',
});
});
it('logout-all вызывается по кнопке и уводит на /login', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
{ match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) },
]);
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { router } = renderApp('/settings/security');
const visitor = userEvent.setup();
expect(await screen.findByTestId('sessions-list')).toHaveTextContent('Firefox on Linux');
await visitor.click(screen.getByRole('button', { name: 'Выйти на всех устройствах' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/login');
});
expect(confirm).toHaveBeenCalled();
expect(
recordedRequests(fetchMock).some(
(request) => request.url.includes('/auth/logout-all') && request.method === 'POST',
),
).toBe(true);
});
it('смена пароля требует step-up и повторяется после подтверждения', async () => {
let passwordAttempts = 0;
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
{
match: '/api/v1/users/@me/password',
method: 'POST',
response: () => {
passwordAttempts += 1;
return passwordAttempts === 1
? apiError('auth.step_up_required', 403)
: json({ ok: true });
},
},
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
]);
renderApp('/settings/security');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Текущий пароль'), 'old-password-1');
await visitor.type(screen.getByLabelText('Новый пароль'), 'new-password-1');
await visitor.type(screen.getByLabelText('Повторите новый пароль'), 'new-password-1');
await visitor.click(screen.getByRole('button', { name: 'Сменить пароль' }));
// Сервер ответил auth.step_up_required — появилась форма подтверждения.
expect(await screen.findByText('Подтвердите личность')).toBeVisible();
await visitor.type(screen.getByLabelText('Ваш пароль'), 'old-password-1');
await visitor.click(screen.getByRole('button', { name: 'Подтвердить' }));
expect(await screen.findByText('Пароль изменён.')).toBeVisible();
expect(
recordedRequests(fetchMock).filter(
(request) => request.url.includes('/users/@me/password') && request.method === 'POST',
),
).toHaveLength(2);
});
it('профиль сохраняется через PATCH /users/@me', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { ...user, display_name: 'Алиса' } }),
},
]);
renderApp('/settings/profile');
const visitor = userEvent.setup();
const displayName = await screen.findByLabelText('Отображаемое имя');
await visitor.clear(displayName);
await visitor.type(displayName, 'Алиса');
await visitor.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByText('Профиль сохранён.')).toBeVisible();
const patchCall = findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' });
expect(patchCall?.body).toMatchObject({ display_name: 'Алиса' });
});
it('вкладка «Инстанс» скрыта от не-администратора', async () => {
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
renderApp('/settings/profile');
expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible();
expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull();
});
it('администратор видит данные инстанса', async () => {
const admin = makeUser({ is_instance_admin: true });
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user: admin }) },
{
match: '/api/v1/instance/settings',
response: () => json({ settings: { motd: 'привет' } }),
},
{
match: '/api/v1/instance/guilds',
response: () =>
json({
guilds: [{ id: 'g-1', name: 'Main', member_count: 3, owner_id: 'u', is_main: true }],
}),
},
{
match: '/api/v1/instance/users',
response: () =>
json({
users: [
{
id: 'u-1',
username: 'alice',
display_name: 'Alice',
is_instance_admin: true,
created_at: '2026-09-01T00:00:00Z',
},
],
}),
},
{
match: '/api/v1/instance/audit',
response: () =>
json({
entries: [
{
id: 'a-1',
actor_id: 'u-1',
action: 'guild.create',
created_at: '2026-09-01T00:00:00Z',
},
],
}),
},
]);
renderApp('/settings/instance');
expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main');
expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice');
expect(screen.getByTestId('instance-audit')).toHaveTextContent('guild.create');
expect(screen.getByText('motd')).toBeVisible();
});
});
+258
View File
@@ -0,0 +1,258 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { vi } from 'vitest';
import { GatewayOp } from '@/api/gateway';
import {
installFetch,
json,
makeReadySnapshot,
makeUser,
recordedRequests,
renderApp,
} from './helpers';
/** Подставной WebSocket: сразу отвечает HELLO и присылает READY. */
class FakeSocket {
static instances: FakeSocket[] = [];
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;
constructor(readonly url: string) {
FakeSocket.instances.push(this);
}
send(): void {
// IDENTIFY/HEARTBEAT от клиента в этом тесте не проверяются.
}
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);
}
}
function installGatewaySocket(snapshot: unknown) {
FakeSocket.instances = [];
vi.stubGlobal('WebSocket', FakeSocket);
return {
/** Ждёт, пока GatewayBridge откроет сокет, и проигрывает рукопожатие. */
async greet() {
await waitFor(() => {
expect(FakeSocket.instances.length).toBeGreaterThan(0);
});
const socket = FakeSocket.instances[0];
if (socket === undefined) {
throw new Error('шлюз не подключился');
}
socket.open();
socket.emit({
op: GatewayOp.HELLO,
d: { heartbeat_interval_ms: 45_000, session_id: 'session-1' },
});
socket.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: snapshot });
},
};
}
const user = makeUser();
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Основной сервер',
is_main: true,
channels: [
{ id: 'cat-1', name: 'Текстовые', type: 'category', position: 0 },
{ id: 'c-general', name: 'общий', position: 1, parent_id: 'cat-1' },
{ id: 'c-readonly', name: 'анонсы', position: 2, parent_id: 'cat-1', can_send: false },
{ id: 'c-voice', name: 'Голосовая', type: 'voice', position: 3 },
],
},
{ id: 'g-2', name: 'Второй сервер' },
]);
describe('основной экран', () => {
it('рендерит серверы и комнаты из снапшота READY', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]);
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-general');
// Ждём, пока AuthGuard отдаст профиль и смонтируется оболочка.
await screen.findByTestId('guild-rail');
await gateway.greet();
// Серверы в рейке.
const rail = screen.getByTestId('guild-rail');
await waitFor(() => {
expect(within(rail).getAllByRole('link')).toHaveLength(2);
});
expect(within(rail).getByLabelText('Открыть сервер Второй сервер')).toBeVisible();
// Комнаты: категория, текстовая, «только чтение» (can_send=false) и голосовая.
expect(await screen.findByLabelText('Открыть комнату общий')).toBeVisible();
expect(screen.getByLabelText('Открыть комнату анонсы')).toBeVisible();
expect(screen.getByLabelText('Открыть комнату Голосовая')).toBeVisible();
expect(screen.getByRole('heading', { name: 'Текстовые' })).toBeVisible();
// Панель пользователя.
const panel = screen.getByLabelText('Панель пользователя');
expect(within(panel).getByText('Alice')).toBeVisible();
expect(within(panel).getByLabelText('Настройки пользователя')).toBeVisible();
expect(within(panel).getByLabelText('Выйти')).toBeVisible();
// Заглушка комнаты вместо чата Фазы 1: шапка комнаты + подсказка.
expect(await screen.findByText('Чат появится в Фазе 2')).toBeVisible();
expect(screen.getByRole('heading', { name: 'общий' })).toBeVisible();
});
it('пустой список серверов предлагает главный сервер и создание своего', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{
match: '/api/v1/instance',
response: () =>
json({
instance: {
name: 'glchat-test',
registration_enabled: true,
allow_guild_creation: true,
max_guilds_per_user: 5,
main_guild_id: 'g-main',
},
}),
},
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
]);
renderApp('/app/empty');
expect(
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
).toBeVisible();
expect(screen.getByLabelText('Создать сервер')).toBeVisible();
});
it('после вступления в главный сервер открывает его в URL', async () => {
const { default: userEvent } = await import('@testing-library/user-event');
const visitor = userEvent.setup();
const mainGuild = {
id: 'g-main',
name: 'Главный',
owner_id: 'user-1',
is_main: true,
member_count: 5,
my_role_ids: [],
my_permissions: [],
};
let joined = false;
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{
match: '/api/v1/instance',
response: () =>
json({
instance: {
name: 'glchat-test',
registration_enabled: true,
allow_guild_creation: true,
max_guilds_per_user: 5,
main_guild_id: 'g-main',
},
}),
},
{
match: '/api/v1/guilds/g-main/join',
method: 'POST',
response: () => {
joined = true;
return json({ ok: true });
},
},
{
match: '/api/v1/users/@me/guilds',
response: () => json({ guilds: joined ? [mainGuild] : [] }),
},
{
match: '/api/v1/guilds/g-main/channels',
response: () =>
json({ channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }] }),
},
]);
const { router } = renderApp('/app/empty');
await visitor.click(
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
);
// В снапшоте сервера может не быть — REST-подстраховка обновляет список,
// после чего пользователь попадает в первую комнату сервера.
await waitFor(() => {
expect(router.state.location.pathname).toStrictEqual(expect.stringContaining('/app/g-main'));
});
});
it('создаёт сервер через модальное окно', async () => {
const { default: userEvent } = await import('@testing-library/user-event');
const visitor = userEvent.setup();
const created = {
id: 'g-new',
name: 'Новый',
owner_id: 'user-1',
is_main: false,
member_count: 1,
my_role_ids: [],
my_permissions: [],
};
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{
match: '/api/v1/instance',
response: () =>
json({
instance: {
name: 'glchat-test',
registration_enabled: true,
allow_guild_creation: true,
max_guilds_per_user: 5,
},
}),
},
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/guilds', method: 'POST', response: () => json({ guild: created }) },
{ match: '/api/v1/guilds/g-new/channels', response: () => json({ channels: [] }) },
{ match: '/api/v1/guilds/g-new', response: () => json({ guild: { ...created, roles: [] } }) },
]);
const { router } = renderApp('/app/empty');
await visitor.click(await screen.findByLabelText('Создать сервер'));
await visitor.type(await screen.findByLabelText('Название сервера'), 'Новый');
await visitor.click(screen.getByRole('button', { name: 'Создать' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/g-new');
});
const createCall = recordedRequests(fetchMock).find(
(request) => request.url.endsWith('/api/v1/guilds') && request.method === 'POST',
);
expect(createCall?.body).toEqual({ name: 'Новый' });
});
});