import { afterEach, expect, test } from "bun:test"; import { createSession, removeSession } from "./store"; import { joinSession } from "./domain"; import { STALE_SOCKET_TIMEOUT_MS, handleSocketMessage, reapStaleSockets, registerSocket, resetWebsocketStateForTests, unregisterSocket, } from "./websocket"; const OPEN = 1; const CLOSED = 3; type FakeSocket = { readyState: number; sent: string[]; closes: Array<{ code?: number; reason?: string }>; send: (payload: string) => void; close: (code?: number, reason?: string) => void; }; function createFakeSocket(): FakeSocket { return { readyState: OPEN, sent: [], closes: [], send(payload: string) { this.sent.push(payload); }, close(code?: number, reason?: string) { this.closes.push({ code, reason }); this.readyState = CLOSED; }, }; } afterEach(() => { resetWebsocketStateForTests(); }); test("overlapping sockets do not disconnect a player until the last socket closes", () => { const { session } = createSession("Banker"); const player = joinSession(session, "Jules"); const firstSocket = createFakeSocket(); const secondSocket = createFakeSocket(); registerSocket(firstSocket as unknown as WebSocket, session.id, player.id); registerSocket(secondSocket as unknown as WebSocket, session.id, player.id); unregisterSocket(firstSocket as unknown as WebSocket); expect(session.players.get(player.id)?.connected).toBe(true); expect(session.players.get(player.id)?.isDummy).toBe(false); unregisterSocket(secondSocket as unknown as WebSocket); expect(session.players.get(player.id)?.connected).toBe(false); expect(session.players.get(player.id)?.isDummy).toBe(true); removeSession(session.id); }); test("stale sockets are reaped and disconnect the player", () => { const { session } = createSession("Banker"); const player = joinSession(session, "Rosa"); const socket = createFakeSocket(); registerSocket(socket as unknown as WebSocket, session.id, player.id); reapStaleSockets(Date.now() + STALE_SOCKET_TIMEOUT_MS + 1); expect(socket.closes[0]).toEqual({ code: 4000, reason: "Connection stale" }); expect(session.players.get(player.id)?.connected).toBe(false); expect(session.players.get(player.id)?.isDummy).toBe(true); removeSession(session.id); }); test("invalid session registration closes the socket with a terminal code", () => { const socket = createFakeSocket(); registerSocket(socket as unknown as WebSocket, "missing-session", "missing-player"); expect(socket.closes[0]).toEqual({ code: 1008, reason: "Session not found" }); }); test("ping messages update liveness and emit a pong", () => { const { session } = createSession("Banker"); const player = joinSession(session, "Nina"); const socket = createFakeSocket(); registerSocket(socket as unknown as WebSocket, session.id, player.id); socket.sent = []; handleSocketMessage( socket as unknown as WebSocket, JSON.stringify({ type: "ping", sessionId: session.id, playerId: player.id }), ); expect(socket.sent.some((entry) => entry.includes('"type":"pong"'))).toBe(true); removeSession(session.id); });