2026-09-19 21:53:35 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 22:03:46 +03:00
|
|
|
|
const connections: GatewayConnection[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
// Закрываем соединения, иначе таймеры heartbeat продолжают жить между тестами.
|
|
|
|
|
|
for (const connection of connections.splice(0)) {
|
|
|
|
|
|
connection.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
FakeSocket.instances = [];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-19 21:53:35 +03:00
|
|
|
|
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' } });
|
2026-09-19 22:03:46 +03:00
|
|
|
|
socket?.emit({
|
|
|
|
|
|
op: GatewayOp.DISPATCH,
|
|
|
|
|
|
t: 'READY',
|
|
|
|
|
|
s: 1,
|
|
|
|
|
|
d: makeReadySnapshot([
|
|
|
|
|
|
{ id: 'g-1', name: 'Main', channels: [{ id: 'c-1', name: 'general' }] },
|
|
|
|
|
|
]),
|
|
|
|
|
|
});
|
2026-09-19 21:53:35 +03:00
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
|
2026-09-19 22:03:46 +03:00
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
2026-09-19 21:53:35 +03:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-19 22:03:46 +03:00
|
|
|
|
it('INVALID_SESSION с resumable=false разлогинивает', () => {
|
2026-09-19 21:53:35 +03:00
|
|
|
|
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' } });
|
2026-09-19 22:03:46 +03:00
|
|
|
|
socket?.emit({
|
|
|
|
|
|
op: GatewayOp.INVALID_SESSION,
|
|
|
|
|
|
d: { reason: 'invalid token', resumable: false },
|
|
|
|
|
|
});
|
2026-09-19 21:53:35 +03:00
|
|
|
|
|
|
|
|
|
|
expect(invalidated).toBe(1);
|
|
|
|
|
|
expect(connection.getStatus()).toBe('disconnected');
|
|
|
|
|
|
connection.close();
|
|
|
|
|
|
});
|
2026-09-19 22:03:46 +03:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-09-19 21:53:35 +03:00
|
|
|
|
});
|