// @ts-nocheck /** * tests/unit/core/SocketHandler.test.js * * Story 1.3 — Full unit coverage for SocketHandler. * Uses vitest fake timers for timeout/retry/revert paths. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SocketHandler, SOCKET_TIMEOUT_MS } from '../../../src/core/SocketHandler.js'; import { MAX_PAYLOAD_BYTES } from '../../../src/contracts/socket-message.js'; // ─── Stubs ──────────────────────────────────────────────────────────────────── function makeSocket() { return { emit: vi.fn(), on: vi.fn(), off: vi.fn() }; } function makeHooks() { return { on: vi.fn(), once: vi.fn() }; } function makeHandler() { return { onRevert: vi.fn() }; } /** * A minimal PendingOp-like object. * @param {string} [opId] * @returns {object} */ function makePendingOp(opId = 'op-test-1') { return { opId, userId: 'user-1', targetState: 'hidden', baseRevision: 0, timeoutId: null }; } // ─── Constructor ───────────────────────────────────────────────────────────── describe('SocketHandler constructor', () => { it('creates instance in not-ready state', () => { const h = new SocketHandler(makeSocket(), makeHooks()); expect(h._isReady).toBe(false); expect(h._pendingOps.size).toBe(0); expect(h._messageQueue).toHaveLength(0); }); }); // ─── setReady() ────────────────────────────────────────────────────────────── describe('SocketHandler.setReady()', () => { it('marks handler as ready', () => { const h = new SocketHandler(makeSocket(), makeHooks()); h.setReady(makeHandler()); expect(h._isReady).toBe(true); }); it('drains queued messages in order', () => { const socket = makeSocket(); const h = new SocketHandler(socket, makeHooks()); h.emit('event-a', { id: 1 }); h.emit('event-b', { id: 2 }); expect(socket.emit).not.toHaveBeenCalled(); h.setReady(makeHandler()); expect(socket.emit).toHaveBeenCalledTimes(2); expect(socket.emit.mock.calls[0]).toEqual(['event-a', { id: 1 }]); expect(socket.emit.mock.calls[1]).toEqual(['event-b', { id: 2 }]); }); it('accepts null handler and logs warn', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const h = new SocketHandler(makeSocket(), makeHooks()); h.setReady(null); expect(h._isReady).toBe(true); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[ScryingPool]')); warnSpy.mockRestore(); }); }); // ─── emit() ────────────────────────────────────────────────────────────────── describe('SocketHandler.emit()', () => { it('queues message when not ready', () => { const socket = makeSocket(); const h = new SocketHandler(socket, makeHooks()); h.emit('test-event', { a: 1 }); expect(socket.emit).not.toHaveBeenCalled(); expect(h._messageQueue).toHaveLength(1); }); it('emits directly when ready', () => { const socket = makeSocket(); const h = new SocketHandler(socket, makeHooks()); h.setReady(makeHandler()); h.emit('test-event', { a: 1 }); expect(socket.emit).toHaveBeenCalledWith('test-event', { a: 1 }); }); it('throws when payload exceeds MAX_PAYLOAD_BYTES', () => { const h = new SocketHandler(makeSocket(), makeHooks()); h.setReady(makeHandler()); const bigPayload = { data: 'x'.repeat(MAX_PAYLOAD_BYTES) }; expect(() => h.emit('test-event', bigPayload)).toThrow(Error); }); it('does NOT throw for payload just under MAX_PAYLOAD_BYTES', () => { const h = new SocketHandler(makeSocket(), makeHooks()); h.setReady(makeHandler()); const payload = { data: 'x'.repeat(MAX_PAYLOAD_BYTES - 20) }; expect(() => h.emit('test-event', payload)).not.toThrow(); }); }); // ─── registerPendingOp() + confirmPendingOp() ───────────────────────────────── describe('SocketHandler.registerPendingOp() and confirmPendingOp()', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('registers a pending op', () => { const h = new SocketHandler(makeSocket(), makeHooks()); const op = makePendingOp(); h.registerPendingOp(op); expect(h._pendingOps.has('op-test-1')).toBe(true); }); it('confirms a pending op — clears it and cancels timer', () => { const h = new SocketHandler(makeSocket(), makeHooks()); const op = makePendingOp(); h.registerPendingOp(op); h.confirmPendingOp('op-test-1'); expect(h._pendingOps.has('op-test-1')).toBe(false); }); it('confirmPendingOp with unknown opId is a no-op (stale echo)', () => { const h = new SocketHandler(makeSocket(), makeHooks()); expect(() => h.confirmPendingOp('unknown-op-id')).not.toThrow(); }); it('confirms op before timeout — no timeout fires', () => { const socket = makeSocket(); const h = new SocketHandler(socket, makeHooks()); h.setReady(makeHandler()); const op = makePendingOp(); h.registerPendingOp(op); h.confirmPendingOp('op-test-1'); vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 3); // No retry emit should have occurred expect(socket.emit).not.toHaveBeenCalled(); }); }); // ─── Timeout → retry → revert ──────────────────────────────────────────────── describe('SocketHandler timeout / retry / revert', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('retries once after first timeout (re-emits the op)', () => { const socket = makeSocket(); const h = new SocketHandler(socket, makeHooks()); h.setReady(makeHandler()); const op = makePendingOp(); h.registerPendingOp(op); vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); expect(socket.emit).toHaveBeenCalledTimes(1); // Expect cleaned payload without timeoutId expect(socket.emit).toHaveBeenCalledWith('scrying-pool.visibility.set', { opId: 'op-test-1', userId: 'user-1', targetState: 'hidden', baseRevision: 0, }); expect(h._pendingOps.has('op-test-1')).toBe(true); // not removed yet }); it('calls handler.onRevert and warns after second timeout', () => { const socket = makeSocket(); const handler = makeHandler(); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const h = new SocketHandler(socket, makeHooks()); h.setReady(handler); const op = makePendingOp(); h.registerPendingOp(op); vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // first timeout → retry vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // second timeout → revert expect(handler.onRevert).toHaveBeenCalledWith(op); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[ScryingPool]'), 'op-test-1'); expect(h._pendingOps.has('op-test-1')).toBe(false); warnSpy.mockRestore(); }); it('does NOT call handler.onRevert when handler is null', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const h = new SocketHandler(makeSocket(), makeHooks()); h.setReady(null); // no handler const op = makePendingOp(); h.registerPendingOp(op); vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 2); // No error thrown is the assertion warnSpy.mockRestore(); }); it('ignores second timeout if op was confirmed between retry and second timeout', () => { const handler = makeHandler(); const h = new SocketHandler(makeSocket(), makeHooks()); h.setReady(handler); const op = makePendingOp(); h.registerPendingOp(op); vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // first timeout → retry h.confirmPendingOp('op-test-1'); // confirmed during retry window vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // second timeout fires but op is gone expect(handler.onRevert).not.toHaveBeenCalled(); }); it('stale echo (confirmPendingOp after second revert) is a no-op', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const h = new SocketHandler(makeSocket(), makeHooks()); h.setReady(makeHandler()); const op = makePendingOp(); h.registerPendingOp(op); vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 2); // full revert expect(() => h.confirmPendingOp('op-test-1')).not.toThrow(); warnSpy.mockRestore(); }); }); // ─── SOCKET_TIMEOUT_MS constant ────────────────────────────────────────────── describe('SOCKET_TIMEOUT_MS', () => { it('is 3000ms', () => { expect(SOCKET_TIMEOUT_MS).toBe(3000); }); });