/** * WebSocket Composable Tests * * Tests for Socket.io connection management, authentication, and event handling. */ // IMPORTANT: Mock process.env BEFORE any other imports to fix Pinia import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { ref } from 'vue' import { setActivePinia, createPinia } from 'pinia' import { useWebSocket } from '~/composables/useWebSocket' ;(globalThis as any).process = { ...((globalThis as any).process || {}), env: { ...((globalThis as any).process?.env || {}), NODE_ENV: 'test', }, client: true, } // Mock Socket.io const mockSocketInstance = { on: vi.fn(), emit: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), connected: false, auth: {}, } vi.mock('socket.io-client', () => ({ io: vi.fn(() => mockSocketInstance), })) // Mock Nuxt runtime config vi.mock('#app', () => ({ useRuntimeConfig: vi.fn(() => ({ public: { wsUrl: 'http://localhost:8000', }, })), })) // Mock stores const mockAuthStore = { isAuthenticated: ref(true), isTokenValid: ref(true), token: 'test-jwt-token', } const mockGameStore = { setConnected: vi.fn(), setGameState: vi.fn(), addPlayToHistory: vi.fn(), setDecisionPrompt: vi.fn(), clearDecisionPrompt: vi.fn(), setPendingRoll: vi.fn(), clearPendingRoll: vi.fn(), updateLineup: vi.fn(), setError: vi.fn(), gameId: 'game-123', } const mockUiStore = { showSuccess: vi.fn(), showError: vi.fn(), showWarning: vi.fn(), showInfo: vi.fn(), } vi.mock('~/store/auth', () => ({ useAuthStore: vi.fn(() => mockAuthStore), })) vi.mock('~/store/game', () => ({ useGameStore: vi.fn(() => mockGameStore), })) vi.mock('~/store/ui', () => ({ useUiStore: vi.fn(() => mockUiStore), })) // TODO: Fix import.meta.client issues for WebSocket operations in tests describe.skip('useWebSocket', () => { beforeEach(() => { // Ensure process.env exists before creating Pinia if (!process.env) { (process as any).env = {} } process.env.NODE_ENV = 'test' setActivePinia(createPinia()) // Reset all mocks vi.clearAllMocks() // Reset mock socket state mockSocketInstance.connected = false mockSocketInstance.on.mockClear() mockSocketInstance.emit.mockClear() mockSocketInstance.connect.mockClear() mockSocketInstance.disconnect.mockClear() // Reset auth store state mockAuthStore.isAuthenticated.value = true mockAuthStore.isTokenValid.value = true // Use fake timers for testing timeouts vi.useFakeTimers() }) afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() }) describe('initialization', () => { it('initializes with disconnected state', () => { const ws = useWebSocket() expect(ws.isConnected.value).toBe(false) expect(ws.isConnecting.value).toBe(false) expect(ws.connectionError.value).toBeNull() expect(ws.socket.value).toBeNull() }) it('computes canConnect based on auth state', () => { const { useAuthStore } = require('~/store/auth') vi.mocked(useAuthStore).mockReturnValue({ ...mockAuthStore, isAuthenticated: ref(true), isTokenValid: ref(true), }) const ws = useWebSocket() // canConnect is a ComputedRef, so use .value expect(ws.canConnect.value).toBe(true) }) it('cannot connect when not authenticated', () => { const { useAuthStore } = require('~/store/auth') vi.mocked(useAuthStore).mockReturnValue({ ...mockAuthStore, isAuthenticated: ref(false), isTokenValid: ref(true), }) const ws = useWebSocket() expect(ws.canConnect.value).toBe(false) }) it('cannot connect when token is invalid', () => { const { useAuthStore } = require('~/store/auth') vi.mocked(useAuthStore).mockReturnValue({ ...mockAuthStore, isAuthenticated: ref(true), isTokenValid: ref(false), }) const ws = useWebSocket() expect(ws.canConnect.value).toBe(false) }) }) describe('connection lifecycle', () => { it('connects with JWT authentication', () => { const { io } = require('socket.io-client') const ws = useWebSocket() ws.connect() expect(io).toHaveBeenCalledWith('http://localhost:8000', { auth: { token: 'test-jwt-token', }, autoConnect: false, reconnection: false, transports: ['websocket', 'polling'], }) expect(mockSocketInstance.connect).toHaveBeenCalled() expect(ws.isConnecting.value).toBe(true) }) it('does not connect when not authenticated', () => { mockAuthStore.isAuthenticated.value = false const ws = useWebSocket() ws.connect() expect(mockSocketInstance.connect).not.toHaveBeenCalled() expect(ws.isConnecting.value).toBe(false) }) it('does not connect when token is invalid', () => { mockAuthStore.isTokenValid.value = false const ws = useWebSocket() ws.connect() expect(mockSocketInstance.connect).not.toHaveBeenCalled() }) it('does not connect when already connected', () => { const ws = useWebSocket() ws.connect() mockSocketInstance.connect.mockClear() // Simulate connected state ws.isConnected.value = true ws.connect() expect(mockSocketInstance.connect).not.toHaveBeenCalled() }) it('updates state on successful connection', () => { const ws = useWebSocket() ws.connect() // Simulate 'connect' event const connectHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'connect' )?.[1] expect(connectHandler).toBeDefined() connectHandler?.() expect(ws.isConnected.value).toBe(true) expect(ws.isConnecting.value).toBe(false) expect(ws.connectionError.value).toBeNull() expect(mockGameStore.setConnected).toHaveBeenCalledWith(true) expect(mockUiStore.showSuccess).toHaveBeenCalledWith('Connected to game server') }) it('disconnects and clears state', () => { const ws = useWebSocket() ws.connect() // Simulate connected const connectHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'connect' )?.[1] connectHandler?.() expect(ws.isConnected.value).toBe(true) // Disconnect ws.disconnect() expect(mockSocketInstance.disconnect).toHaveBeenCalled() expect(ws.isConnected.value).toBe(false) expect(ws.isConnecting.value).toBe(false) }) it('handles disconnection event', () => { const ws = useWebSocket() ws.connect() // Simulate 'disconnect' event const disconnectHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'disconnect' )?.[1] expect(disconnectHandler).toBeDefined() disconnectHandler?.('transport close') expect(ws.isConnected.value).toBe(false) expect(mockGameStore.setConnected).toHaveBeenCalledWith(false) expect(mockUiStore.showWarning).toHaveBeenCalledWith('Disconnected from game server') }) it('handles connection error', () => { const ws = useWebSocket() ws.connect() // Simulate 'connect_error' event const errorHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'connect_error' )?.[1] expect(errorHandler).toBeDefined() const error = new Error('Connection failed') errorHandler?.(error) expect(ws.isConnected.value).toBe(false) expect(ws.isConnecting.value).toBe(false) expect(ws.connectionError.value).toBe('Connection failed') expect(mockGameStore.setError).toHaveBeenCalledWith('Connection failed') expect(mockUiStore.showError).toHaveBeenCalledWith('Connection failed: Connection failed') }) }) describe('exponential backoff reconnection', () => { it('calculates exponential backoff delay correctly', () => { const ws = useWebSocket() ws.connect() // Get disconnect handler const disconnectHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'disconnect' )?.[1] // Trigger disconnect (not intentional) disconnectHandler?.('transport close') // Should schedule reconnection expect(setTimeout).toHaveBeenCalled() // First attempt should be 1000ms (2^0 * 1000) const firstCall = vi.mocked(setTimeout).mock.calls[0] expect(firstCall[1]).toBe(1000) }) it('increases delay with each failed attempt', () => { const ws = useWebSocket() // Simulate multiple connection failures for (let i = 0; i < 3; i++) { ws.connect() const errorHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'connect_error' )?.[1] errorHandler?.(new Error('Connection failed')) vi.advanceTimersByTime(100000) // Fast-forward timers } // Check that delays increase exponentially const timeoutCalls = vi.mocked(setTimeout).mock.calls const delays = timeoutCalls.map((call) => call[1]).filter((delay) => delay !== 30000) // Filter out heartbeat // First 3 attempts: 1000ms, 2000ms, 4000ms expect(delays).toContain(1000) expect(delays).toContain(2000) expect(delays).toContain(4000) }) it('caps reconnection delay at maximum', () => { const ws = useWebSocket() // Simulate many connection failures to exceed max delay for (let i = 0; i < 10; i++) { ws.connect() const errorHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'connect_error' )?.[1] errorHandler?.(new Error('Connection failed')) vi.advanceTimersByTime(100000) } // Check that delay never exceeds 30000ms const timeoutCalls = vi.mocked(setTimeout).mock.calls const delays = timeoutCalls.map((call) => call[1]) // All delays should be <= 30000ms delays.forEach((delay) => { expect(delay).toBeLessThanOrEqual(30000) }) }) it('does not reconnect on intentional disconnect', () => { const ws = useWebSocket() ws.connect() const disconnectHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'disconnect' )?.[1] vi.clearAllTimers() const timeoutsBefore = vi.getTimerCount() // Trigger intentional disconnect disconnectHandler?.('io client disconnect') const timeoutsAfter = vi.getTimerCount() // Should not schedule reconnection expect(timeoutsAfter).toBe(timeoutsBefore) }) it('stops reconnecting after max attempts', () => { const ws = useWebSocket() // Simulate 10 failed attempts (max is 10) for (let i = 0; i < 11; i++) { ws.connect() const errorHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'connect_error' )?.[1] errorHandler?.(new Error('Connection failed')) vi.advanceTimersByTime(100000) } // After 10 attempts, should not schedule more reconnections // This is tested implicitly - the 11th attempt should not schedule a timeout }) it('resets reconnection attempts on successful connection', () => { const ws = useWebSocket() // Fail once ws.connect() const errorHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'connect_error' )?.[1] errorHandler?.(new Error('Connection failed')) // Then succeed const connectHandler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'connect' )?.[1] connectHandler?.() expect(ws.isConnected.value).toBe(true) // Reconnection attempts should be reset to 0 // (tested implicitly through state) }) }) describe('event handler registration', () => { it('registers connection event handlers', () => { const ws = useWebSocket() ws.connect() const registeredEvents = mockSocketInstance.on.mock.calls.map(([event]) => event) expect(registeredEvents).toContain('connect') expect(registeredEvents).toContain('disconnect') expect(registeredEvents).toContain('connect_error') expect(registeredEvents).toContain('connected') expect(registeredEvents).toContain('heartbeat_ack') }) it('registers game state event handlers', () => { const ws = useWebSocket() ws.connect() const registeredEvents = mockSocketInstance.on.mock.calls.map(([event]) => event) expect(registeredEvents).toContain('game_state_update') expect(registeredEvents).toContain('game_state_sync') expect(registeredEvents).toContain('play_completed') expect(registeredEvents).toContain('inning_change') expect(registeredEvents).toContain('game_ended') }) it('registers decision event handlers', () => { const ws = useWebSocket() ws.connect() const registeredEvents = mockSocketInstance.on.mock.calls.map(([event]) => event) expect(registeredEvents).toContain('decision_required') expect(registeredEvents).toContain('defensive_decision_submitted') expect(registeredEvents).toContain('offensive_decision_submitted') }) it('registers manual workflow event handlers', () => { const ws = useWebSocket() ws.connect() const registeredEvents = mockSocketInstance.on.mock.calls.map(([event]) => event) expect(registeredEvents).toContain('dice_rolled') expect(registeredEvents).toContain('outcome_accepted') }) it('registers error event handlers', () => { const ws = useWebSocket() ws.connect() const registeredEvents = mockSocketInstance.on.mock.calls.map(([event]) => event) expect(registeredEvents).toContain('error') expect(registeredEvents).toContain('outcome_rejected') expect(registeredEvents).toContain('substitution_error') expect(registeredEvents).toContain('invalid_action') expect(registeredEvents).toContain('connection_error') }) }) describe('game state event handling', () => { it('handles game_state_update event', () => { const ws = useWebSocket() ws.connect() const handler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'game_state_update' )?.[1] const mockState = { game_id: 'game-123', inning: 5 } handler?.(mockState) expect(mockGameStore.setGameState).toHaveBeenCalledWith(mockState) }) it('handles game_state_sync event with recent plays', () => { const ws = useWebSocket() ws.connect() const handler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'game_state_sync' )?.[1] const mockData = { state: { game_id: 'game-123', inning: 3 }, recent_plays: [ { play_number: 1, outcome: 'SINGLE_1', description: 'Single' }, { play_number: 2, outcome: 'OUT', description: 'Out' }, ], } handler?.(mockData) expect(mockGameStore.setGameState).toHaveBeenCalledWith(mockData.state) expect(mockGameStore.addPlayToHistory).toHaveBeenCalledTimes(2) }) it('handles play_completed event', () => { const ws = useWebSocket() ws.connect() const handler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'play_completed' )?.[1] const mockPlay = { play_number: 1, outcome: 'SINGLE_1', description: 'Single to center', } handler?.(mockPlay) expect(mockGameStore.addPlayToHistory).toHaveBeenCalledWith(mockPlay) expect(mockUiStore.showInfo).toHaveBeenCalledWith('Single to center', 3000) }) it('handles decision_required event', () => { const ws = useWebSocket() ws.connect() const handler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'decision_required' )?.[1] const mockPrompt = { phase: 'defense', role: 'home', timeout_seconds: 30, } handler?.(mockPrompt) expect(mockGameStore.setDecisionPrompt).toHaveBeenCalledWith(mockPrompt) }) it('handles dice_rolled event', () => { const ws = useWebSocket() ws.connect() const handler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'dice_rolled' )?.[1] const mockRollData = { roll_id: 'roll-123', d6_one: 4, d6_two_total: 7, chaos_d20: 15, resolution_d20: 12, check_wild_pitch: false, check_passed_ball: false, timestamp: '2025-01-10T12:00:00Z', message: 'Dice rolled!', } handler?.(mockRollData) expect(mockGameStore.setPendingRoll).toHaveBeenCalledWith( expect.objectContaining({ roll_id: 'roll-123', d6_one: 4, d6_two_total: 7, chaos_d20: 15, resolution_d20: 12, }) ) expect(mockUiStore.showInfo).toHaveBeenCalledWith('Dice rolled!', 5000) }) }) describe('error event handling', () => { it('handles server error event', () => { const ws = useWebSocket() ws.connect() const handler = mockSocketInstance.on.mock.calls.find(([event]) => event === 'error')?.[1] handler?.({ message: 'Server error occurred' }) expect(mockGameStore.setError).toHaveBeenCalledWith('Server error occurred') expect(mockUiStore.showError).toHaveBeenCalledWith('Server error occurred', 7000) }) it('handles outcome_rejected event', () => { const ws = useWebSocket() ws.connect() const handler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'outcome_rejected' )?.[1] handler?.({ message: 'Invalid outcome' }) expect(mockUiStore.showError).toHaveBeenCalledWith('Invalid outcome: Invalid outcome', 7000) }) it('handles invalid_action event', () => { const ws = useWebSocket() ws.connect() const handler = mockSocketInstance.on.mock.calls.find( ([event]) => event === 'invalid_action' )?.[1] handler?.({ reason: 'Not your turn' }) expect(mockUiStore.showError).toHaveBeenCalledWith('Invalid action: Not your turn', 7000) }) }) describe('JWT token update on reconnection', () => { it('updates auth token when reconnecting', () => { const { io } = require('socket.io-client') const ws = useWebSocket() // First connection ws.connect() // Change token mockAuthStore.token = 'new-jwt-token' // Reconnect (socket instance exists) ws.connect() // Should update auth token expect(mockSocketInstance.auth).toEqual({ token: 'new-jwt-token', }) }) it('creates new socket with auth on first connection', () => { const { io } = require('socket.io-client') const ws = useWebSocket() ws.connect() expect(io).toHaveBeenCalledWith( 'http://localhost:8000', expect.objectContaining({ auth: { token: 'test-jwt-token', }, }) ) }) }) })