9c001fedfe
Клиент 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`), клиент сверяет имена.
239 lines
8.2 KiB
TypeScript
239 lines
8.2 KiB
TypeScript
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();
|
||
});
|
||
});
|