import { describe, it, expect, beforeEach, vi } from 'vitest'; import { SSEHub } from './hub.js'; import { EventEmitter } from 'node:events'; import type { ServerResponse } from 'node:http'; function mockResponse(): ServerResponse { const emitter = new EventEmitter(); const chunks: string[] = []; const res = Object.assign(emitter, { writeHead: vi.fn(), write: vi.fn((chunk: string) => { chunks.push(chunk); return true; }), end: vi.fn(), _chunks: chunks, }); return res as unknown as ServerResponse; } describe('SSEHub', () => { let hub: SSEHub; beforeEach(() => { hub = new SSEHub(); }); describe('addClient', () => { it('returns a client ID and sets SSE headers', () => { const res = mockResponse(); const id = hub.addClient(res); expect(id).toMatch(/^sse_/); expect(res.writeHead).toHaveBeenCalledWith( 200, expect.objectContaining({ 'Content-Type': 'text/event-stream', }) ); expect(hub.clientCount).toBe(1); }); it('sends initial connected event', () => { const res = mockResponse(); hub.addClient(res); expect(res.write).toHaveBeenCalledWith(expect.stringContaining('event: connected')); }); it('removes client on close', () => { const res = mockResponse(); hub.addClient(res); expect(hub.clientCount).toBe(1); res.emit('close'); expect(hub.clientCount).toBe(0); }); }); describe('broadcast', () => { it('sends to all connected clients', () => { const res1 = mockResponse(); const res2 = mockResponse(); hub.addClient(res1); hub.addClient(res2); const sent = hub.broadcast({ event: 'test', data: '{"hello":true}' }); expect(sent).toBe(2); // Each res gets: initial connected write + broadcast write = 2 calls expect(res1.write).toHaveBeenCalledTimes(2); expect(res2.write).toHaveBeenCalledTimes(2); }); it('returns 0 when no clients', () => { const sent = hub.broadcast({ data: 'test' }); expect(sent).toBe(0); }); it('removes clients that throw on write', () => { const res1 = mockResponse(); const res2 = mockResponse(); hub.addClient(res1); hub.addClient(res2); // Make res1 throw on next write (res1.write as ReturnType).mockImplementationOnce(() => { throw new Error('broken'); }); hub.broadcast({ data: 'test' }); // res1 should be removed after the error expect(hub.clientCount).toBe(1); }); }); describe('sendToUser', () => { it('sends only to matching userId', () => { const res1 = mockResponse(); const res2 = mockResponse(); hub.addClient(res1, 'user-a'); hub.addClient(res2, 'user-b'); const sent = hub.sendToUser('user-a', { data: 'targeted' }); expect(sent).toBe(1); // res1: connected + targeted = 2 writes expect(res1.write).toHaveBeenCalledTimes(2); // res2: only connected = 1 write expect(res2.write).toHaveBeenCalledTimes(1); }); it('returns 0 when no matching users', () => { const res = mockResponse(); hub.addClient(res, 'user-a'); const sent = hub.sendToUser('user-z', { data: 'nothing' }); expect(sent).toBe(0); }); }); describe('heartbeat', () => { it('sends comment to all clients', () => { const res = mockResponse(); hub.addClient(res); hub.heartbeat(); expect(res.write).toHaveBeenCalledWith(': heartbeat\n\n'); }); }); describe('disconnectAll', () => { it('ends all client responses and clears', () => { const res1 = mockResponse(); const res2 = mockResponse(); hub.addClient(res1); hub.addClient(res2); expect(hub.clientCount).toBe(2); hub.disconnectAll(); expect(hub.clientCount).toBe(0); expect(res1.end).toHaveBeenCalled(); expect(res2.end).toHaveBeenCalled(); }); }); describe('formatSSE', () => { it('formats with event, id, and data', () => { const res = mockResponse(); hub.addClient(res); hub.broadcast({ event: 'task.created', data: '{}', id: 'evt_1' }); const lastWrite = (res.write as ReturnType).mock.calls.at(-1)?.[0] as string; expect(lastWrite).toContain('id: evt_1'); expect(lastWrite).toContain('event: task.created'); expect(lastWrite).toContain('data: {}'); expect(lastWrite).toMatch(/\n\n$/); }); it('formats with retry field', () => { const res = mockResponse(); hub.addClient(res); hub.broadcast({ data: 'test', retry: 5000 }); const lastWrite = (res.write as ReturnType).mock.calls.at(-1)?.[0] as string; expect(lastWrite).toContain('retry: 5000'); }); }); });