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:
@@ -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: 'Новый' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user