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(); }); });