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>
26 KiB
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)
connecthandler (authentication)disconnecthandler (cleanup)join_game/leave_gamehandlers
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