# 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
