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>
678 lines
26 KiB
Markdown
678 lines
26 KiB
Markdown
# 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
|