Files
glchat/web/tests/gateway.test.ts
T

192 lines
6.5 KiB
TypeScript
Raw Normal View History

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);
}
}
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;
}
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({});
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, 200));
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('reports INVALID_SESSION to the handler', () => {
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 });
expect(invalidated).toBe(1);
expect(connection.getStatus()).toBe('disconnected');
connection.close();
});
});