strat-gameplay-webapp/.claude/plans/008-websocket-tests.md
Cal Corum e0c12467b0 CLAUDE: Improve UX with single-click OAuth, enhanced games list, and layout fix
Frontend UX improvements:
- Single-click Discord OAuth from home page (no intermediate /auth page)
- Auto-redirect authenticated users from home to /games
- Fixed Nuxt layout system - app.vue now wraps NuxtPage with NuxtLayout
- Games page now has proper card container with shadow/border styling
- Layout header includes working logout with API cookie clearing

Games list enhancements:
- Display team names (lname) instead of just team IDs
- Show current score for each team
- Show inning indicator (Top/Bot X) for active games
- Responsive header with wrapped buttons on mobile

Backend improvements:
- Added team caching to SbaApiClient (1-hour TTL)
- Enhanced GameListItem with team names, scores, inning data
- Games endpoint now enriches response with SBA API team data

Docker optimizations:
- Optimized Dockerfile using --chown flag on COPY (faster than chown -R)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:14:00 -06:00

26 KiB

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:

"""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:

"""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:

"""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:

"""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:

"""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)

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