Files
grendervill 9c001fedfe 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`), клиент сверяет имена.
2026-09-19 22:03:46 +03:00

239 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it } from 'vitest';
import {
GatewayConnection,
GatewayOp,
parseGatewaySnapshot,
type GatewayDispatch,
type GatewayStatus,
} from '@/api/gateway';
import { makeReadySnapshot } from './helpers';
/** Подставной WebSocket: рукопожатие и heartbeat полностью под контролем теста. */
class FakeSocket {
static instances: FakeSocket[] = [];
readonly url: string;
readyState = 0;
sent: string[] = [];
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(url: string) {
this.url = url;
FakeSocket.instances.push(this);
}
open(): void {
this.readyState = 1;
this.onopen?.(new Event('open'));
}
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.readyState = 3;
}
/** Присылает кадр от сервера. */
emit(packet: unknown): void {
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
}
/** Последний отправленный пакет в виде объекта. */
lastSent(): { op: number; d: unknown } {
const raw = this.sent.at(-1);
if (raw === undefined) {
throw new Error('socket has not sent anything');
}
return JSON.parse(raw) as { op: number; d: unknown };
}
closeFromServer(code = 1006): void {
this.readyState = 3;
this.onclose?.({ code } as CloseEvent);
}
}
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;
onInvalidSession?: () => void;
onResumeIncomplete?: () => void;
}) {
FakeSocket.instances = [];
const connection = new GatewayConnection({
url: 'ws://localhost/gateway',
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
handlers,
// Быстрый backoff, чтобы тест переподключения не ждал секунду.
baseBackoffMs: 5,
helloTimeoutMs: 60_000,
});
connection.connect();
connections.push(connection);
return connection;
}
describe('gateway client', () => {
it('answers HELLO with IDENTIFY and an empty token', () => {
const connection = makeConnection({});
const socket = FakeSocket.instances[0];
expect(socket).toBeDefined();
socket?.open();
socket?.emit({
op: GatewayOp.HELLO,
d: { heartbeat_interval_ms: 1000, session_id: 'sess-1' },
});
expect(socket?.lastSent()).toEqual({ op: GatewayOp.IDENTIFY, d: { token: '', resume_seq: 0 } });
expect(connection.getStatus()).toBe('connecting');
connection.close();
});
it('delivers READY to the dispatch handler', () => {
const events: GatewayDispatch[] = [];
const connection = makeConnection({ onDispatch: (event) => events.push(event) });
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' }] },
]),
});
expect(events).toHaveLength(1);
expect(events[0]?.t).toBe('READY');
expect(connection.getStatus()).toBe('connected');
expect(connection.getLastSeq()).toBe(1);
const snapshot = parseGatewaySnapshot(events[0]?.d);
expect(snapshot?.guilds[0]?.channels[0]?.name).toBe('general');
connection.close();
});
it('ignores unknown events and unparsable frames', () => {
const events: GatewayDispatch[] = [];
const connection = makeConnection({ onDispatch: (event) => events.push(event) });
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: 'GUILD_CREATE', s: 2, d: { id: 'g-9' } });
socket?.emit({ op: 99, d: null });
socket?.onmessage?.({ data: 'not json' } as MessageEvent);
expect(events.map((event) => event.t)).toEqual(['GUILD_CREATE']);
connection.close();
});
it('sends HEARTBEAT with the interval from HELLO and tracks the ack', async () => {
const connection = makeConnection({});
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 20, session_id: 's' } });
await new Promise((resolve) => setTimeout(resolve, 45));
const heartbeat = socket?.lastSent();
expect(heartbeat).toEqual({ op: GatewayOp.HEARTBEAT, d: null });
socket?.emit({ op: GatewayOp.HEARTBEAT_ACK, d: null });
connection.close();
});
it('reconnects with RESUME and the last sequence number', async () => {
const statuses: GatewayStatus[] = [];
const connection = makeConnection({ onStatus: (status) => statuses.push(status) });
const first = FakeSocket.instances[0];
first?.open();
first?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 'sess-7' } });
first?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 42, d: makeReadySnapshot([]) });
first?.closeFromServer();
expect(connection.getStatus()).toBe('reconnecting');
await new Promise((resolve) => setTimeout(resolve, 100));
const second = FakeSocket.instances[1];
expect(second).toBeDefined();
second?.open();
second?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 'sess-7' } });
expect(second?.lastSent()).toEqual({ op: GatewayOp.RESUME, d: { token: '', resume_seq: 42 } });
expect(statuses).toContain('reconnecting');
connection.close();
});
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: { 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();
});
});