import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { setupWebSocketHandler } from '../../src/websocket-handler.js' import { EventEmitter } from 'events' // Mock parziale di una WebSocket e del Server class MockWebSocket extends EventEmitter { constructor() { super() this.readyState = 1 // OPEN } send = vi.fn() terminate = vi.fn() } class MockWebSocketServer extends EventEmitter { clients = new Set() } // Helper: connette e registra un client function connectAndRegister(wss, role) { const ws = new MockWebSocket() wss.emit('connection', ws) wss.clients.add(ws) ws.emit('message', JSON.stringify({ type: 'register', role })) ws.send.mockClear() return ws } // Helper: ultimo messaggio inviato a un ws function lastSent(ws) { const calls = ws.send.mock.calls return JSON.parse(calls[calls.length - 1][0]) } describe('WebSocket Integration (websocket-handler.js)', () => { let wss let handler beforeEach(() => { wss = new MockWebSocketServer() handler = setupWebSocketHandler(wss) }) afterEach(() => { vi.restoreAllMocks() }) // ============================================= // REGISTRAZIONE // ============================================= describe('Registrazione', () => { it('dovrebbe registrare un client come "display" e inviare lo stato', () => { const ws = new MockWebSocket() wss.emit('connection', ws) wss.clients.add(ws) ws.emit('message', JSON.stringify({ type: 'register', role: 'display' })) expect(ws.send).toHaveBeenCalled() const sentMsg = JSON.parse(ws.send.mock.calls[0][0]) expect(sentMsg.type).toBe('state') expect(sentMsg.state).toBeDefined() }) it('dovrebbe registrare un client come "controller"', () => { connectAndRegister(wss, 'controller') expect(handler.getClients().size).toBe(1) }) it('dovrebbe rifiutare ruolo non valido', () => { const ws = new MockWebSocket() wss.emit('connection', ws) wss.clients.add(ws) ws.emit('message', JSON.stringify({ type: 'register', role: 'hacker' })) const sentMsg = JSON.parse(ws.send.mock.calls[0][0]) expect(sentMsg.type).toBe('error') expect(sentMsg.message).toContain('Invalid role') }) it('dovrebbe usare "display" come ruolo default se mancante', () => { const ws = new MockWebSocket() wss.emit('connection', ws) wss.clients.add(ws) ws.emit('message', JSON.stringify({ type: 'register' })) const sentMsg = JSON.parse(ws.send.mock.calls[0][0]) expect(sentMsg.type).toBe('state') }) }) // ============================================= // AZIONI // ============================================= describe('Azioni', () => { it('dovrebbe permettere al controller di cambiare il punteggio', () => { const controller = connectAndRegister(wss, 'controller') controller.emit('message', JSON.stringify({ type: 'action', action: { type: 'incPunt', team: 'home' } })) expect(controller.send).toHaveBeenCalled() const sentMsg = lastSent(controller) expect(sentMsg.type).toBe('state') expect(sentMsg.state.sp.punt.home).toBe(1) }) it('dovrebbe impedire al display di inviare azioni', () => { const display = connectAndRegister(wss, 'display') display.emit('message', JSON.stringify({ type: 'action', action: { type: 'incPunt', team: 'home' } })) const sentMsg = lastSent(display) expect(sentMsg.type).toBe('error') expect(sentMsg.message).toContain('Only controllers') }) it('dovrebbe impedire azioni da client non registrati', () => { const ws = new MockWebSocket() wss.emit('connection', ws) wss.clients.add(ws) ws.emit('message', JSON.stringify({ type: 'action', action: { type: 'incPunt', team: 'home' } })) const sentMsg = JSON.parse(ws.send.mock.calls[0][0]) expect(sentMsg.type).toBe('error') expect(sentMsg.message).toContain('Only controllers') }) it('dovrebbe rifiutare azione con formato invalido (missing action)', () => { const controller = connectAndRegister(wss, 'controller') controller.emit('message', JSON.stringify({ type: 'action' })) const sentMsg = lastSent(controller) expect(sentMsg.type).toBe('error') expect(sentMsg.message).toContain('Invalid action format') }) it('dovrebbe rifiutare azione con formato invalido (missing action.type)', () => { const controller = connectAndRegister(wss, 'controller') controller.emit('message', JSON.stringify({ type: 'action', action: { team: 'home' } })) const sentMsg = lastSent(controller) expect(sentMsg.type).toBe('error') expect(sentMsg.message).toContain('Invalid action format') }) }) // ============================================= // BROADCAST MULTI-CLIENT // ============================================= describe('Broadcast', () => { it('dovrebbe inviare lo stato a tutti i client dopo un\'azione', () => { const controller = connectAndRegister(wss, 'controller') const display1 = connectAndRegister(wss, 'display') const display2 = connectAndRegister(wss, 'display') controller.emit('message', JSON.stringify({ type: 'action', action: { type: 'incPunt', team: 'home' } })) // Tutti i client nel set dovrebbero aver ricevuto lo stato expect(controller.send).toHaveBeenCalled() expect(display1.send).toHaveBeenCalled() expect(display2.send).toHaveBeenCalled() const msg1 = lastSent(display1) const msg2 = lastSent(display2) expect(msg1.type).toBe('state') expect(msg1.state.sp.punt.home).toBe(1) expect(msg2.state.sp.punt.home).toBe(1) }) it('non dovrebbe inviare a client con readyState != OPEN', () => { const controller = connectAndRegister(wss, 'controller') const closedClient = connectAndRegister(wss, 'display') closedClient.readyState = 3 // CLOSED controller.emit('message', JSON.stringify({ type: 'action', action: { type: 'incPunt', team: 'home' } })) // closedClient non dovrebbe aver ricevuto il broadcast expect(closedClient.send).not.toHaveBeenCalled() }) }) // ============================================= // SPEAK // ============================================= describe('Speak', () => { it('dovrebbe inoltrare il messaggio speak solo ai display', () => { const controller = connectAndRegister(wss, 'controller') const display = connectAndRegister(wss, 'display') controller.emit('message', JSON.stringify({ type: 'speak', text: 'quindici a dieci' })) // Il display riceve il messaggio speak expect(display.send).toHaveBeenCalled() const msg = lastSent(display) expect(msg.type).toBe('speak') expect(msg.text).toBe('quindici a dieci') }) it('non dovrebbe permettere al display di inviare speak', () => { const display = connectAndRegister(wss, 'display') display.emit('message', JSON.stringify({ type: 'speak', text: 'test' })) const msg = lastSent(display) expect(msg.type).toBe('error') expect(msg.message).toContain('Only controllers') }) it('dovrebbe rifiutare speak con testo vuoto', () => { const controller = connectAndRegister(wss, 'controller') controller.emit('message', JSON.stringify({ type: 'speak', text: ' ' })) const msg = lastSent(controller) expect(msg.type).toBe('error') expect(msg.message).toContain('Invalid speak payload') }) it('dovrebbe rifiutare speak senza testo', () => { const controller = connectAndRegister(wss, 'controller') controller.emit('message', JSON.stringify({ type: 'speak' })) const msg = lastSent(controller) expect(msg.type).toBe('error') }) it('dovrebbe fare trim del testo speak', () => { const controller = connectAndRegister(wss, 'controller') const display = connectAndRegister(wss, 'display') controller.emit('message', JSON.stringify({ type: 'speak', text: ' dieci a otto ' })) const msg = lastSent(display) expect(msg.text).toBe('dieci a otto') }) }) // ============================================= // MESSAGGI MALFORMATI // ============================================= describe('Messaggi malformati', () => { it('dovrebbe gestire JSON non valido senza crash', () => { const ws = new MockWebSocket() wss.emit('connection', ws) wss.clients.add(ws) expect(() => { ws.emit('message', 'questo non รจ JSON {{{') }).not.toThrow() const msg = lastSent(ws) expect(msg.type).toBe('error') expect(msg.message).toContain('Invalid message format') }) it('dovrebbe gestire Buffer come input', () => { const controller = connectAndRegister(wss, 'controller') const buf = Buffer.from(JSON.stringify({ type: 'action', action: { type: 'incPunt', team: 'home' } })) controller.emit('message', buf) const msg = lastSent(controller) expect(msg.type).toBe('state') expect(msg.state.sp.punt.home).toBe(1) }) }) // ============================================= // DISCONNESSIONE // ============================================= describe('Disconnessione', () => { it('dovrebbe rimuovere il client dalla mappa alla disconnessione', () => { const controller = connectAndRegister(wss, 'controller') expect(handler.getClients().size).toBe(1) controller.emit('close') expect(handler.getClients().size).toBe(0) }) it('i client rimanenti non dovrebbero essere affetti dalla disconnessione', () => { const controller = connectAndRegister(wss, 'controller') const display = connectAndRegister(wss, 'display') expect(handler.getClients().size).toBe(2) controller.emit('close') expect(handler.getClients().size).toBe(1) expect(handler.getClients().has(display)).toBe(true) }) }) // ============================================= // ERRORI WEBSOCKET // ============================================= describe('Errori WebSocket', () => { it('dovrebbe terminare la connessione per errore UTF8 invalido', () => { const ws = new MockWebSocket() wss.emit('connection', ws) const err = new Error('Invalid UTF8') err.code = 'WS_ERR_INVALID_UTF8' ws.emit('error', err) expect(ws.terminate).toHaveBeenCalled() }) it('dovrebbe terminare la connessione per close code invalido', () => { const ws = new MockWebSocket() wss.emit('connection', ws) const err = new Error('Invalid close code') err.code = 'WS_ERR_INVALID_CLOSE_CODE' ws.emit('error', err) expect(ws.terminate).toHaveBeenCalled() }) it('non dovrebbe terminare per altri errori', () => { const ws = new MockWebSocket() wss.emit('connection', ws) const err = new Error('Generic error') ws.emit('error', err) expect(ws.terminate).not.toHaveBeenCalled() }) }) // ============================================= // API PUBBLICA // ============================================= describe('API pubblica', () => { it('getState dovrebbe restituire lo stato corrente', () => { const state = handler.getState() expect(state.sp.punt.home).toBe(0) expect(state.sp.punt.guest).toBe(0) }) it('setState dovrebbe sovrascrivere lo stato', () => { const newState = handler.getState() newState.sp.punt.home = 99 handler.setState(newState) expect(handler.getState().sp.punt.home).toBe(99) }) it('broadcastState dovrebbe inviare a tutti i client', () => { const display = connectAndRegister(wss, 'display') handler.broadcastState() expect(display.send).toHaveBeenCalled() const msg = lastSent(display) expect(msg.type).toBe('state') }) it('getClients dovrebbe restituire la mappa dei client', () => { expect(handler.getClients()).toBeInstanceOf(Map) expect(handler.getClients().size).toBe(0) connectAndRegister(wss, 'display') expect(handler.getClients().size).toBe(1) }) }) })