import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { ioMock } = vi.hoisted(() => ({ ioMock: vi.fn(), })); vi.mock('socket.io-client', () => ({ io: ioMock, })); import { destroySocket, getSocket } from './socket'; interface MockChatSocket { on: ReturnType; offAny: ReturnType; disconnect: ReturnType; /** Test-only helper: fires every handler registered for `event` via * `.on`, mirroring how a real socket.io-client instance invokes its own * listeners (e.g. calling the registered `disconnect` handler(s) on a * real transient disconnect). */ trigger(event: string): void; } function createMockSocket(): MockChatSocket { const handlers = new Map void>>(); const mockSocket: MockChatSocket = { on: vi.fn((event: string, handler: () => void) => { if (!handlers.has(event)) handlers.set(event, new Set()); handlers.get(event)?.add(handler); return mockSocket; }), offAny: vi.fn(() => mockSocket), disconnect: vi.fn(() => mockSocket), trigger(event: string): void { for (const handler of handlers.get(event) ?? []) handler(); }, }; return mockSocket; } let currentMock!: MockChatSocket; describe('chat socket', () => { beforeEach(() => { ioMock.mockReset(); // A fresh object per io() call so identity assertions (same singleton vs. // a genuinely new instance) are meaningful. ioMock.mockImplementation(() => { currentMock = createMockSocket(); return currentMock; }); }); afterEach(() => { destroySocket(); }); it('creates one same-origin /chat namespace socket', () => { const first = getSocket(); const second = getSocket(); expect(first).toBe(second); expect(ioMock).toHaveBeenCalledOnce(); expect(ioMock).toHaveBeenCalledWith('/chat', { withCredentials: true, autoConnect: false, transports: ['websocket', 'polling'], }); }); it('keeps the same singleton instance across a transient disconnect', () => { const first = getSocket(); // socket.ts must not react to a real socket's `disconnect` event by // nulling the singleton — it registers no such handler at all now. // Actually fire every handler registered via `.on('disconnect', ...)` // (mirroring a real socket.io-client reconnect) instead of merely // calling getSocket() again: this is what makes the test fail if // production reintroduces `socket.on('disconnect', () => { socket = // null; })`, since that handler would run here and null the singleton // before the next getSocket() call. currentMock.trigger('disconnect'); const second = getSocket(); expect(second).toBe(first); expect(ioMock).toHaveBeenCalledOnce(); }); it('only creates a new singleton after an explicit destroySocket()', () => { const first = getSocket(); destroySocket(); const second = getSocket(); expect(second).not.toBe(first); expect(ioMock).toHaveBeenCalledTimes(2); }); });