strat-gameplay-webapp/backend/tests/unit/websocket/test_query_handlers.py
Cal Corum 4253b71db9 CLAUDE: Enhance WebSocket handlers with comprehensive test coverage
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>
2025-11-28 12:08:43 -06:00

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