WebSocket Infrastructure: - Connection manager: Improved connection/disconnection handling - Handlers: Enhanced event handlers for game operations Test Coverage (148 new tests): - test_connection_handlers.py: Connection lifecycle tests - test_connection_manager.py: Manager operations tests - test_handler_locking.py: Concurrency/locking tests - test_query_handlers.py: Game query handler tests - test_rate_limiting.py: Rate limit enforcement tests - test_substitution_handlers.py: Player substitution tests - test_manual_outcome_handlers.py: Manual outcome workflow tests - conftest.py: Shared WebSocket test fixtures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
445 lines
16 KiB
Python
445 lines
16 KiB
Python
"""
|
|
Tests for WebSocket query and decision handlers.
|
|
|
|
Verifies lineup retrieval (get_lineup), box score (get_box_score),
|
|
and strategic decision submission handlers (submit_defensive_decision,
|
|
submit_offensive_decision).
|
|
|
|
Author: Claude
|
|
Date: 2025-01-27
|
|
"""
|
|
|
|
import pytest
|
|
from uuid import uuid4
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
# ============================================================================
|
|
# GET LINEUP HANDLER TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestGetLineupHandler:
|
|
"""Tests for the get_lineup event handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_lineup_from_cache(self, mock_manager, mock_lineup_state):
|
|
"""
|
|
Verify get_lineup() returns cached lineup when available.
|
|
|
|
StateManager caches lineups for fast O(1) lookup. When lineup is
|
|
in cache, handler should return it without hitting database.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
game_id = uuid4()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_lineup.return_value = mock_lineup_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["get_lineup"]
|
|
|
|
await handler("test_sid", {"game_id": str(game_id), "team_id": 1})
|
|
|
|
mock_state_mgr.get_lineup.assert_called_once_with(game_id, 1)
|
|
mock_manager.emit_to_user.assert_called_once()
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "lineup_data"
|
|
assert call_args[0][2]["team_id"] == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_lineup_from_db_when_not_cached(
|
|
self, mock_manager, mock_lineup_state, mock_game_state
|
|
):
|
|
"""
|
|
Verify get_lineup() loads from database when not in cache.
|
|
|
|
When lineup is not cached, handler should load from database
|
|
via lineup_service and cache the result.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
game_id = uuid4()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.lineup_service") as mock_lineup_svc:
|
|
mock_state_mgr.get_lineup.return_value = None # Not cached
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_lineup_svc.load_team_lineup_with_player_data = AsyncMock(
|
|
return_value=mock_lineup_state
|
|
)
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["get_lineup"]
|
|
|
|
await handler("test_sid", {"game_id": str(game_id), "team_id": 1})
|
|
|
|
mock_lineup_svc.load_team_lineup_with_player_data.assert_called_once()
|
|
mock_state_mgr.set_lineup.assert_called_once() # Cache it
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_lineup_missing_game_id(self, mock_manager):
|
|
"""
|
|
Verify get_lineup() returns error when game_id missing.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["get_lineup"]
|
|
|
|
await handler("test_sid", {"team_id": 1})
|
|
|
|
mock_manager.emit_to_user.assert_called_once()
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "error"
|
|
assert "game_id" in call_args[0][2]["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_lineup_missing_team_id(self, mock_manager):
|
|
"""
|
|
Verify get_lineup() returns error when team_id missing.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["get_lineup"]
|
|
|
|
await handler("test_sid", {"game_id": str(uuid4())})
|
|
|
|
mock_manager.emit_to_user.assert_called_once()
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "error"
|
|
assert "team_id" in call_args[0][2]["message"].lower()
|
|
|
|
|
|
# ============================================================================
|
|
# GET BOX SCORE HANDLER TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestGetBoxScoreHandler:
|
|
"""Tests for the get_box_score event handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_box_score_success(self, mock_manager):
|
|
"""
|
|
Verify get_box_score() returns box score data from service.
|
|
|
|
Handler should fetch box score from materialized views via
|
|
box_score_service and emit to requester.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
game_id = uuid4()
|
|
mock_box_score = {
|
|
"home_team": {"runs": 5, "hits": 10, "errors": 1},
|
|
"away_team": {"runs": 3, "hits": 7, "errors": 2},
|
|
}
|
|
|
|
# box_score_service is imported inside the handler via:
|
|
# from app.services import box_score_service
|
|
with patch("app.services.box_score_service") as mock_service:
|
|
mock_service.get_box_score = AsyncMock(return_value=mock_box_score)
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["get_box_score"]
|
|
|
|
await handler("test_sid", {"game_id": str(game_id)})
|
|
|
|
mock_service.get_box_score.assert_called_once_with(game_id)
|
|
mock_manager.emit_to_user.assert_called_once()
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "box_score_data"
|
|
assert call_args[0][2]["box_score"] == mock_box_score
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_box_score_not_found(self, mock_manager):
|
|
"""
|
|
Verify get_box_score() returns error when no data found.
|
|
|
|
When materialized views return no data (e.g., game hasn't started),
|
|
handler should emit error with helpful hint about migrations.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
# box_score_service is imported inside the handler
|
|
with patch("app.services.box_score_service") as mock_service:
|
|
mock_service.get_box_score = AsyncMock(return_value=None)
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["get_box_score"]
|
|
|
|
await handler("test_sid", {"game_id": str(uuid4())})
|
|
|
|
mock_manager.emit_to_user.assert_called_once()
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_box_score_missing_game_id(self, mock_manager):
|
|
"""
|
|
Verify get_box_score() returns error when game_id missing.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["get_box_score"]
|
|
|
|
await handler("test_sid", {})
|
|
|
|
mock_manager.emit_to_user.assert_called_once()
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "error"
|
|
|
|
|
|
# ============================================================================
|
|
# SUBMIT DEFENSIVE DECISION HANDLER TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestSubmitDefensiveDecisionHandler:
|
|
"""Tests for the submit_defensive_decision event handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_defensive_decision_success(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""
|
|
Verify submit_defensive_decision() processes and broadcasts decision.
|
|
|
|
Handler should create DefensiveDecision, submit to game engine,
|
|
and broadcast to game room so both teams see the defense strategy.
|
|
|
|
Valid values:
|
|
- infield_depth: infield_in, normal, corners_in
|
|
- outfield_depth: normal, shallow
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.game_engine") as mock_engine:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
updated_state = MagicMock()
|
|
updated_state.pending_decision = None
|
|
mock_engine.submit_defensive_decision = AsyncMock(return_value=updated_state)
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["submit_defensive_decision"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"infield_depth": "infield_in", # Valid: infield_in, normal, corners_in
|
|
"outfield_depth": "shallow", # Valid: normal, shallow
|
|
"hold_runners": [1],
|
|
},
|
|
)
|
|
|
|
mock_engine.submit_defensive_decision.assert_called_once()
|
|
mock_manager.broadcast_to_game.assert_called_once()
|
|
|
|
call_args = mock_manager.broadcast_to_game.call_args
|
|
assert call_args[0][1] == "defensive_decision_submitted"
|
|
assert call_args[0][2]["decision"]["infield_depth"] == "infield_in"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_defensive_decision_uses_defaults(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""
|
|
Verify submit_defensive_decision() uses default values when not provided.
|
|
|
|
Handler should default to normal alignment, normal depth, no hold runners
|
|
when these fields are omitted from the request.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.game_engine") as mock_engine:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
updated_state = MagicMock()
|
|
updated_state.pending_decision = None
|
|
mock_engine.submit_defensive_decision = AsyncMock(return_value=updated_state)
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["submit_defensive_decision"]
|
|
|
|
await handler("test_sid", {"game_id": str(mock_game_state.game_id)})
|
|
|
|
call_args = mock_manager.broadcast_to_game.call_args
|
|
decision = call_args[0][2]["decision"]
|
|
assert decision["alignment"] == "normal"
|
|
assert decision["infield_depth"] == "normal"
|
|
assert decision["outfield_depth"] == "normal"
|
|
assert decision["hold_runners"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_defensive_decision_game_not_found(self, mock_manager):
|
|
"""
|
|
Verify submit_defensive_decision() returns error when game not found.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = None
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["submit_defensive_decision"]
|
|
|
|
await handler("test_sid", {"game_id": str(uuid4())})
|
|
|
|
mock_manager.emit_to_user.assert_called_once()
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "error"
|
|
assert "not found" in call_args[0][2]["message"].lower()
|
|
|
|
|
|
# ============================================================================
|
|
# SUBMIT OFFENSIVE DECISION HANDLER TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestSubmitOffensiveDecisionHandler:
|
|
"""Tests for the submit_offensive_decision event handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_offensive_decision_success(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""
|
|
Verify submit_offensive_decision() processes and broadcasts decision.
|
|
|
|
Handler should create OffensiveDecision with action and steal attempts,
|
|
submit to game engine, and broadcast to game room.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.game_engine") as mock_engine:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
updated_state = MagicMock()
|
|
updated_state.pending_decision = None
|
|
mock_engine.submit_offensive_decision = AsyncMock(return_value=updated_state)
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["submit_offensive_decision"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"action": "steal",
|
|
"steal_attempts": [2],
|
|
},
|
|
)
|
|
|
|
mock_engine.submit_offensive_decision.assert_called_once()
|
|
mock_manager.broadcast_to_game.assert_called_once()
|
|
|
|
call_args = mock_manager.broadcast_to_game.call_args
|
|
assert call_args[0][1] == "offensive_decision_submitted"
|
|
assert call_args[0][2]["decision"]["action"] == "steal"
|
|
assert call_args[0][2]["decision"]["steal_attempts"] == [2]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_offensive_decision_defaults_to_swing_away(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""
|
|
Verify submit_offensive_decision() defaults to swing_away action.
|
|
|
|
When no action is provided, handler should default to swing_away
|
|
which is the most common offensive approach.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.game_engine") as mock_engine:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
updated_state = MagicMock()
|
|
updated_state.pending_decision = None
|
|
mock_engine.submit_offensive_decision = AsyncMock(return_value=updated_state)
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["submit_offensive_decision"]
|
|
|
|
await handler("test_sid", {"game_id": str(mock_game_state.game_id)})
|
|
|
|
call_args = mock_manager.broadcast_to_game.call_args
|
|
decision = call_args[0][2]["decision"]
|
|
assert decision["action"] == "swing_away"
|
|
assert decision["steal_attempts"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_offensive_decision_game_not_found(self, mock_manager):
|
|
"""
|
|
Verify submit_offensive_decision() returns error when game not found.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = None
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["submit_offensive_decision"]
|
|
|
|
await handler("test_sid", {"game_id": str(uuid4())})
|
|
|
|
mock_manager.emit_to_user.assert_called_once()
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "error"
|
|
assert "not found" in call_args[0][2]["message"].lower()
|