# Plan 008: WebSocket Handler Tests **Priority**: HIGH **Effort**: 3-4 days **Status**: NOT STARTED **Risk Level**: MEDIUM - Integration risk --- ## Problem Statement Only 36% of WebSocket handlers have tests (4 of 11). Untested handlers include: - All 3 substitution handlers (~540 lines) - `connect` handler (authentication) - `disconnect` handler (cleanup) - `join_game` / `leave_game` handlers This creates risk for frontend integration as bugs won't be caught. ## Impact - **Integration**: Frontend can't safely integrate - **Regressions**: Changes may break untested handlers - **Confidence**: Team can't verify WebSocket behavior ## Current Test Coverage | Handler | Lines | Tests | Coverage | |---------|-------|-------|----------| | `connect` | ~50 | 0 | ❌ 0% | | `disconnect` | ~20 | 0 | ❌ 0% | | `join_game` | ~30 | 0 | ❌ 0% | | `leave_game` | ~20 | 0 | ❌ 0% | | `submit_defensive_decision` | ~100 | 3 | ✅ Partial | | `submit_offensive_decision` | ~100 | 3 | ✅ Partial | | `roll_dice` | ~80 | 5 | ✅ Good | | `submit_manual_outcome` | ~100 | 7 | ✅ Good | | `request_pinch_hitter` | ~180 | 0 | ❌ 0% | | `request_defensive_replacement` | ~180 | 0 | ❌ 0% | | `request_pitching_change` | ~180 | 0 | ❌ 0% | **Total**: ~1,040 lines, 18 tests → ~40% coverage ## Files to Create | File | Tests | |------|-------| | `tests/unit/websocket/test_connect_handler.py` | 8 | | `tests/unit/websocket/test_disconnect_handler.py` | 5 | | `tests/unit/websocket/test_join_leave_handlers.py` | 8 | | `tests/unit/websocket/test_pinch_hitter_handler.py` | 10 | | `tests/unit/websocket/test_defensive_replacement_handler.py` | 10 | | `tests/unit/websocket/test_pitching_change_handler.py` | 10 | **Total**: ~51 new tests ## Implementation Steps ### Step 1: Create Test Fixtures (1 hour) Create `backend/tests/unit/websocket/conftest.py`: ```python """Shared fixtures for WebSocket handler tests.""" import pytest from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import socketio from app.models.game_models import GameState, LineupPlayerState from app.core.state_manager import StateManager @pytest.fixture def mock_sio(): """Mock Socket.io server.""" sio = MagicMock(spec=socketio.AsyncServer) sio.emit = AsyncMock() sio.enter_room = AsyncMock() sio.leave_room = AsyncMock() sio.disconnect = AsyncMock() return sio @pytest.fixture def mock_manager(): """Mock ConnectionManager.""" manager = MagicMock() manager.connect = AsyncMock() manager.disconnect = AsyncMock() manager.join_game = AsyncMock() manager.leave_game = AsyncMock() manager.broadcast_to_game = AsyncMock() manager.emit_to_user = AsyncMock() manager.get_user_id = AsyncMock(return_value=123) manager.update_activity = AsyncMock() return manager @pytest.fixture def mock_state_manager(): """Mock StateManager.""" sm = MagicMock(spec=StateManager) sm.get_game_state = MagicMock() sm.game_lock = MagicMock() return sm @pytest.fixture def sample_game_id(): """Sample game UUID.""" return uuid4() @pytest.fixture def sample_game_state(sample_game_id): """Sample game state for testing.""" return GameState( game_id=sample_game_id, league_id="sba", home_team_id=1, away_team_id=2, inning=1, half="top", outs=0, home_score=0, away_score=0, current_batter=LineupPlayerState( lineup_id=1, card_id=101, position="CF", batting_order=1 ), current_pitcher=LineupPlayerState( lineup_id=10, card_id=201, position="P", batting_order=None ), ) @pytest.fixture def sample_lineup(): """Sample lineup data.""" return [ LineupPlayerState(lineup_id=i, card_id=100+i, position="P" if i == 0 else "CF", batting_order=i) for i in range(1, 10) ] @pytest.fixture def valid_auth_token(): """Valid JWT token for testing.""" return {"token": "valid_jwt_token_here"} @pytest.fixture def mock_auth(mock_manager): """Mock authentication utilities.""" with patch('app.websocket.handlers.extract_user_id_from_token', return_value=123): with patch('app.websocket.handlers.get_user_role_in_game', return_value="home"): yield ``` ### Step 2: Test connect Handler (2 hours) Create `backend/tests/unit/websocket/test_connect_handler.py`: ```python """Tests for WebSocket connect handler.""" import pytest from unittest.mock import AsyncMock, patch, MagicMock class TestConnectHandler: """Tests for the connect event handler.""" @pytest.mark.asyncio async def test_connect_with_valid_token_succeeds(self, mock_sio, mock_manager, valid_auth_token): """Connection with valid JWT token succeeds.""" with patch('app.websocket.handlers.extract_user_id_from_token', return_value=123): with patch('app.websocket.handlers.manager', mock_manager): from app.websocket.handlers import connect await connect("sid123", {}, valid_auth_token) mock_manager.connect.assert_called_once() # Should not emit error mock_sio.emit.assert_not_called() @pytest.mark.asyncio async def test_connect_with_invalid_token_fails(self, mock_sio, mock_manager): """Connection with invalid token is rejected.""" with patch('app.websocket.handlers.extract_user_id_from_token', return_value=None): with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): from app.websocket.handlers import connect result = await connect("sid123", {}, {"token": "invalid"}) # Should return False to reject connection assert result is False @pytest.mark.asyncio async def test_connect_without_auth_allowed_for_spectator(self, mock_sio, mock_manager): """Connection without auth allowed if spectators enabled.""" with patch('app.websocket.handlers.manager', mock_manager): from app.websocket.handlers import connect await connect("sid123", {}, None) # Should connect as anonymous mock_manager.connect.assert_called_once() @pytest.mark.asyncio async def test_connect_extracts_ip_address(self, mock_manager): """Connection extracts IP from environ.""" environ = {"REMOTE_ADDR": "192.168.1.1"} with patch('app.websocket.handlers.manager', mock_manager): from app.websocket.handlers import connect await connect("sid123", environ, None) # Verify IP passed to manager call_args = mock_manager.connect.call_args assert call_args.kwargs.get("ip_address") == "192.168.1.1" @pytest.mark.asyncio async def test_connect_with_cookie_auth(self, mock_manager): """Connection can authenticate via cookie.""" environ = {"HTTP_COOKIE": "auth_token=valid_token"} with patch('app.websocket.handlers.extract_user_from_cookie', return_value=456): with patch('app.websocket.handlers.manager', mock_manager): from app.websocket.handlers import connect await connect("sid123", environ, None) call_args = mock_manager.connect.call_args assert call_args.kwargs.get("user_id") == 456 @pytest.mark.asyncio async def test_connect_logs_connection(self, mock_manager, caplog): """Connection is logged.""" with patch('app.websocket.handlers.manager', mock_manager): from app.websocket.handlers import connect await connect("sid123", {}, None) assert "sid123" in caplog.text @pytest.mark.asyncio async def test_connect_handles_token_decode_error(self, mock_sio, mock_manager): """Connection handles token decode errors gracefully.""" with patch('app.websocket.handlers.extract_user_id_from_token', side_effect=ValueError("Invalid")): with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): from app.websocket.handlers import connect result = await connect("sid123", {}, {"token": "malformed"}) assert result is False @pytest.mark.asyncio async def test_connect_initializes_rate_limiter(self, mock_manager): """Connection initializes rate limiter bucket.""" with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.rate_limiter') as mock_limiter: from app.websocket.handlers import connect await connect("sid123", {}, None) # Rate limiter should track this connection # (implementation dependent) ``` ### Step 3: Test disconnect Handler (1 hour) Create `backend/tests/unit/websocket/test_disconnect_handler.py`: ```python """Tests for WebSocket disconnect handler.""" import pytest from unittest.mock import AsyncMock, patch class TestDisconnectHandler: """Tests for the disconnect event handler.""" @pytest.mark.asyncio async def test_disconnect_removes_session(self, mock_manager): """Disconnect removes session from manager.""" with patch('app.websocket.handlers.manager', mock_manager): from app.websocket.handlers import disconnect await disconnect("sid123") mock_manager.disconnect.assert_called_once_with("sid123") @pytest.mark.asyncio async def test_disconnect_cleans_rate_limiter(self, mock_manager): """Disconnect cleans up rate limiter.""" with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.rate_limiter') as mock_limiter: from app.websocket.handlers import disconnect await disconnect("sid123") mock_limiter.remove_connection.assert_called_once_with("sid123") @pytest.mark.asyncio async def test_disconnect_leaves_all_games(self, mock_manager): """Disconnect leaves all game rooms.""" # Setup: user is in games mock_manager.get_session = AsyncMock(return_value=MagicMock( games={uuid4(), uuid4()} )) with patch('app.websocket.handlers.manager', mock_manager): from app.websocket.handlers import disconnect await disconnect("sid123") # Should leave all games mock_manager.disconnect.assert_called() @pytest.mark.asyncio async def test_disconnect_notifies_game_participants(self, mock_manager, mock_sio): """Disconnect notifies other players in game.""" game_id = uuid4() mock_manager.get_session = AsyncMock(return_value=MagicMock( user_id=123, games={game_id} )) with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): from app.websocket.handlers import disconnect await disconnect("sid123") # Should broadcast player_left to game room mock_manager.broadcast_to_game.assert_called() @pytest.mark.asyncio async def test_disconnect_logs_event(self, mock_manager, caplog): """Disconnect is logged.""" with patch('app.websocket.handlers.manager', mock_manager): from app.websocket.handlers import disconnect await disconnect("sid123") assert "sid123" in caplog.text or "disconnect" in caplog.text.lower() ``` ### Step 4: Test join_game / leave_game (2 hours) Create `backend/tests/unit/websocket/test_join_leave_handlers.py`: ```python """Tests for join_game and leave_game handlers.""" import pytest from unittest.mock import AsyncMock, patch from uuid import uuid4 class TestJoinGameHandler: """Tests for the join_game event handler.""" @pytest.mark.asyncio async def test_join_game_authorized_user_succeeds(self, mock_manager, mock_sio, sample_game_id): """Authorized user can join game.""" data = {"game_id": str(sample_game_id)} with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): with patch('app.websocket.handlers.require_game_participant', return_value=True): from app.websocket.handlers import join_game await join_game("sid123", data) mock_manager.join_game.assert_called_once() mock_sio.enter_room.assert_called() @pytest.mark.asyncio async def test_join_game_unauthorized_user_rejected(self, mock_manager, mock_sio, sample_game_id): """Unauthorized user cannot join game.""" data = {"game_id": str(sample_game_id)} with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): with patch('app.websocket.handlers.require_game_participant', return_value=False): from app.websocket.handlers import join_game await join_game("sid123", data) mock_manager.join_game.assert_not_called() @pytest.mark.asyncio async def test_join_game_sends_current_state(self, mock_manager, mock_sio, sample_game_id, sample_game_state): """Joining game sends current game state.""" data = {"game_id": str(sample_game_id)} with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): with patch('app.websocket.handlers.require_game_participant', return_value=True): with patch('app.websocket.handlers.state_manager') as mock_sm: mock_sm.get_game_state.return_value = sample_game_state from app.websocket.handlers import join_game await join_game("sid123", data) # Should emit game_state to joining client mock_sio.emit.assert_called() call_args = mock_sio.emit.call_args assert call_args[0][0] == "game_state" @pytest.mark.asyncio async def test_join_game_notifies_other_players(self, mock_manager, mock_sio, sample_game_id): """Joining game notifies other players.""" data = {"game_id": str(sample_game_id)} with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): with patch('app.websocket.handlers.require_game_participant', return_value=True): from app.websocket.handlers import join_game await join_game("sid123", data) # Should broadcast player_joined mock_manager.broadcast_to_game.assert_called() @pytest.mark.asyncio async def test_join_game_invalid_game_id_error(self, mock_manager, mock_sio): """Invalid game ID returns error.""" data = {"game_id": "not-a-uuid"} with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): from app.websocket.handlers import join_game await join_game("sid123", data) mock_sio.emit.assert_called_with("error", pytest.ANY, to="sid123") class TestLeaveGameHandler: """Tests for the leave_game event handler.""" @pytest.mark.asyncio async def test_leave_game_removes_from_room(self, mock_manager, mock_sio, sample_game_id): """Leaving game removes from room.""" data = {"game_id": str(sample_game_id)} with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): from app.websocket.handlers import leave_game await leave_game("sid123", data) mock_manager.leave_game.assert_called_once() mock_sio.leave_room.assert_called() @pytest.mark.asyncio async def test_leave_game_notifies_other_players(self, mock_manager, mock_sio, sample_game_id): """Leaving game notifies other players.""" data = {"game_id": str(sample_game_id)} with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): from app.websocket.handlers import leave_game await leave_game("sid123", data) mock_manager.broadcast_to_game.assert_called() @pytest.mark.asyncio async def test_leave_game_not_in_game_silent(self, mock_manager, mock_sio, sample_game_id): """Leaving game you're not in is silent (no error).""" data = {"game_id": str(sample_game_id)} mock_manager.get_session = AsyncMock(return_value=MagicMock(games=set())) with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): from app.websocket.handlers import leave_game await leave_game("sid123", data) # No error emitted error_calls = [c for c in mock_sio.emit.call_args_list if c[0][0] == "error"] assert len(error_calls) == 0 ``` ### Step 5: Test Substitution Handlers (4 hours each) Create `backend/tests/unit/websocket/test_pinch_hitter_handler.py`: ```python """Tests for request_pinch_hitter handler.""" import pytest from unittest.mock import AsyncMock, patch, MagicMock from uuid import uuid4 class TestPinchHitterHandler: """Tests for the request_pinch_hitter event handler.""" @pytest.fixture def valid_pinch_hitter_data(self, sample_game_id): return { "game_id": str(sample_game_id), "team_id": 1, "entering_player_id": 20, # Bench player "exiting_player_id": 5, # Current batter } @pytest.mark.asyncio async def test_pinch_hitter_valid_request_succeeds( self, mock_manager, mock_sio, mock_state_manager, valid_pinch_hitter_data, sample_game_state ): """Valid pinch hitter request succeeds.""" mock_state_manager.get_game_state.return_value = sample_game_state with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): with patch('app.websocket.handlers.state_manager', mock_state_manager): with patch('app.websocket.handlers.require_team_control', return_value=True): with patch('app.websocket.handlers.substitution_manager') as mock_sub: mock_sub.process_pinch_hitter = AsyncMock(return_value=MagicMock( success=True, to_dict=lambda: {"type": "pinch_hitter"} )) from app.websocket.handlers import request_pinch_hitter await request_pinch_hitter("sid123", valid_pinch_hitter_data) # Should broadcast substitution mock_manager.broadcast_to_game.assert_called() @pytest.mark.asyncio async def test_pinch_hitter_unauthorized_rejected( self, mock_manager, mock_sio, valid_pinch_hitter_data ): """Unauthorized user cannot request pinch hitter.""" with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): with patch('app.websocket.handlers.require_team_control', return_value=False): from app.websocket.handlers import request_pinch_hitter await request_pinch_hitter("sid123", valid_pinch_hitter_data) # Error should be emitted mock_sio.emit.assert_called_with("error", pytest.ANY, to="sid123") @pytest.mark.asyncio async def test_pinch_hitter_invalid_player_rejected( self, mock_manager, mock_sio, mock_state_manager, valid_pinch_hitter_data, sample_game_state ): """Invalid entering player is rejected.""" mock_state_manager.get_game_state.return_value = sample_game_state valid_pinch_hitter_data["entering_player_id"] = 999 # Non-existent with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): with patch('app.websocket.handlers.state_manager', mock_state_manager): with patch('app.websocket.handlers.require_team_control', return_value=True): with patch('app.websocket.handlers.substitution_manager') as mock_sub: mock_sub.process_pinch_hitter = AsyncMock(return_value=MagicMock( success=False, error="Player not on roster" )) from app.websocket.handlers import request_pinch_hitter await request_pinch_hitter("sid123", valid_pinch_hitter_data) # Error should be emitted mock_sio.emit.assert_called_with("error", pytest.ANY, to="sid123") @pytest.mark.asyncio async def test_pinch_hitter_wrong_time_rejected( self, mock_manager, mock_sio, mock_state_manager, valid_pinch_hitter_data, sample_game_state ): """Pinch hitter at wrong time (mid-at-bat) is rejected.""" sample_game_state.at_bat_in_progress = True mock_state_manager.get_game_state.return_value = sample_game_state with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): with patch('app.websocket.handlers.state_manager', mock_state_manager): with patch('app.websocket.handlers.require_team_control', return_value=True): with patch('app.websocket.handlers.substitution_manager') as mock_sub: mock_sub.process_pinch_hitter = AsyncMock(return_value=MagicMock( success=False, error="Cannot substitute during at-bat" )) from app.websocket.handlers import request_pinch_hitter await request_pinch_hitter("sid123", valid_pinch_hitter_data) mock_sio.emit.assert_called_with("error", pytest.ANY, to="sid123") @pytest.mark.asyncio async def test_pinch_hitter_updates_lineup( self, mock_manager, mock_sio, mock_state_manager, valid_pinch_hitter_data, sample_game_state ): """Successful pinch hitter updates lineup state.""" mock_state_manager.get_game_state.return_value = sample_game_state with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): with patch('app.websocket.handlers.state_manager', mock_state_manager): with patch('app.websocket.handlers.require_team_control', return_value=True): with patch('app.websocket.handlers.substitution_manager') as mock_sub: mock_sub.process_pinch_hitter = AsyncMock(return_value=MagicMock( success=True, to_dict=lambda: {"type": "pinch_hitter"} )) from app.websocket.handlers import request_pinch_hitter await request_pinch_hitter("sid123", valid_pinch_hitter_data) # Should persist to database # (verify db_ops call if applicable) @pytest.mark.asyncio async def test_pinch_hitter_rate_limited( self, mock_manager, mock_sio, valid_pinch_hitter_data ): """Rapid pinch hitter requests are rate limited.""" with patch('app.websocket.handlers.rate_limiter') as mock_limiter: mock_limiter.check_game_limit = AsyncMock(return_value=False) with patch('app.websocket.handlers.manager', mock_manager): with patch('app.websocket.handlers.sio', mock_sio): from app.websocket.handlers import request_pinch_hitter await request_pinch_hitter("sid123", valid_pinch_hitter_data) # Rate limit error mock_sio.emit.assert_called() # Additional tests for: # - Player already used validation # - Batting order maintenance # - Database persistence # - Concurrent request handling ``` Create similar test files for: - `test_defensive_replacement_handler.py` (10 tests) - `test_pitching_change_handler.py` (10 tests) ### Step 6: Run Full Test Suite (30 min) ```bash cd /mnt/NV2/Development/strat-gameplay-webapp/backend # Run all WebSocket tests pytest tests/unit/websocket/ -v # Run with coverage pytest tests/unit/websocket/ -v --cov=app/websocket --cov-report=html ``` ## Verification Checklist - [ ] All 11 handlers have tests - [ ] Coverage > 80% for `handlers.py` - [ ] Authorization tests verify access control - [ ] Rate limiting tests verify throttling - [ ] Error handling tests verify error messages - [ ] All new tests pass ## Test Summary Target | Handler | Tests | Status | |---------|-------|--------| | `connect` | 8 | TODO | | `disconnect` | 5 | TODO | | `join_game` | 5 | TODO | | `leave_game` | 3 | TODO | | `submit_defensive_decision` | 5 | Expand | | `submit_offensive_decision` | 5 | Expand | | `roll_dice` | 5 | ✅ | | `submit_manual_outcome` | 7 | ✅ | | `request_pinch_hitter` | 10 | TODO | | `request_defensive_replacement` | 10 | TODO | | `request_pitching_change` | 10 | TODO | | **Total** | **73** | - | ## Rollback Plan Tests are additive - no rollback needed. ## Dependencies - Plan 001 (Authorization) - tests assume auth utilities exist - Plan 002 (Locking) - tests verify concurrent access ## Notes - Consider adding property-based tests for edge cases - May want integration tests for full WebSocket flow - Future: Add load tests for concurrent connections