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>
514 lines
18 KiB
Python
514 lines
18 KiB
Python
"""
|
|
Tests for WebSocket handler locking mechanism.
|
|
|
|
Verifies that state-modifying handlers properly acquire game locks to prevent
|
|
race conditions from concurrent operations on the same game.
|
|
|
|
These tests validate the fix implemented in MASTER_TRACKER task 002.
|
|
|
|
Author: Claude
|
|
Date: 2025-01-27
|
|
"""
|
|
|
|
import asyncio
|
|
import pytest
|
|
from contextlib import asynccontextmanager
|
|
from uuid import uuid4
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from tests.unit.websocket.conftest import get_handler
|
|
|
|
|
|
# ============================================================================
|
|
# ROLL_DICE LOCKING TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestRollDiceLocking:
|
|
"""Tests for locking behavior in roll_dice handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_roll_dice_acquires_lock(
|
|
self, mock_manager, mock_game_state, mock_ab_roll
|
|
):
|
|
"""
|
|
Verify roll_dice acquires game lock before modifying state.
|
|
|
|
The handler should call state_manager.game_lock() as an async context
|
|
manager before setting state.pending_manual_roll.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
lock_acquired = False
|
|
lock_game_id = None
|
|
|
|
@asynccontextmanager
|
|
async def tracking_lock(game_id, timeout=30.0):
|
|
nonlocal lock_acquired, lock_game_id
|
|
lock_acquired = True
|
|
lock_game_id = game_id
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.dice_system") as mock_dice:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.update_state = MagicMock()
|
|
mock_state_mgr.game_lock = tracking_lock
|
|
mock_dice.roll_ab.return_value = mock_ab_roll
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["roll_dice"]
|
|
|
|
await handler("test_sid", {"game_id": str(mock_game_state.game_id)})
|
|
|
|
assert lock_acquired, "Handler should acquire game lock"
|
|
assert lock_game_id == mock_game_state.game_id, "Lock should be for correct game"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_roll_dice_releases_lock_on_success(
|
|
self, mock_manager, mock_game_state, mock_ab_roll
|
|
):
|
|
"""
|
|
Verify roll_dice releases lock after successful operation.
|
|
|
|
The lock should be released even after successful state modification
|
|
to allow subsequent operations.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
lock_released = False
|
|
|
|
@asynccontextmanager
|
|
async def tracking_lock(game_id, timeout=30.0):
|
|
nonlocal lock_released
|
|
try:
|
|
yield
|
|
finally:
|
|
lock_released = True
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.dice_system") as mock_dice:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.update_state = MagicMock()
|
|
mock_state_mgr.game_lock = tracking_lock
|
|
mock_dice.roll_ab.return_value = mock_ab_roll
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["roll_dice"]
|
|
|
|
await handler("test_sid", {"game_id": str(mock_game_state.game_id)})
|
|
|
|
assert lock_released, "Lock should be released after success"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_roll_dice_releases_lock_on_error(self, mock_manager, mock_game_state):
|
|
"""
|
|
Verify roll_dice releases lock even when an error occurs.
|
|
|
|
The lock must be released in the finally block to prevent deadlocks.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
lock_released = False
|
|
|
|
@asynccontextmanager
|
|
async def tracking_lock(game_id, timeout=30.0):
|
|
nonlocal lock_released
|
|
try:
|
|
yield
|
|
finally:
|
|
lock_released = True
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.dice_system") as mock_dice:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.update_state = MagicMock()
|
|
mock_state_mgr.game_lock = tracking_lock
|
|
# Simulate dice system error
|
|
mock_dice.roll_ab.side_effect = RuntimeError("Dice error")
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["roll_dice"]
|
|
|
|
await handler("test_sid", {"game_id": str(mock_game_state.game_id)})
|
|
|
|
assert lock_released, "Lock should be released even on error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_roll_dice_timeout_returns_error(self, mock_manager):
|
|
"""
|
|
Verify roll_dice returns user-friendly error on lock timeout.
|
|
|
|
When the lock cannot be acquired within the timeout period, the handler
|
|
should emit a 'server busy' error message to the user.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
@asynccontextmanager
|
|
async def timeout_lock(game_id, timeout=30.0):
|
|
raise asyncio.TimeoutError(f"Lock timeout for game {game_id}")
|
|
yield # Never reached
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = MagicMock(game_id=uuid4(), league_id="sba")
|
|
mock_state_mgr.game_lock = timeout_lock
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["roll_dice"]
|
|
|
|
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 "busy" in call_args[0][2]["message"].lower()
|
|
|
|
|
|
# ============================================================================
|
|
# SUBMIT_MANUAL_OUTCOME LOCKING TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestSubmitOutcomeLocking:
|
|
"""Tests for locking behavior in submit_manual_outcome handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_outcome_acquires_lock(
|
|
self, mock_manager, mock_game_state, mock_ab_roll, mock_play_result
|
|
):
|
|
"""
|
|
Verify submit_manual_outcome acquires lock before processing.
|
|
|
|
The lock must be acquired before checking/clearing pending_manual_roll
|
|
to prevent race conditions with concurrent submissions.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
lock_acquired = False
|
|
mock_game_state.pending_manual_roll = mock_ab_roll
|
|
|
|
@asynccontextmanager
|
|
async def tracking_lock(game_id, timeout=30.0):
|
|
nonlocal lock_acquired
|
|
lock_acquired = True
|
|
yield
|
|
|
|
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
|
|
mock_state_mgr.update_state = MagicMock()
|
|
mock_state_mgr.game_lock = tracking_lock
|
|
mock_engine.resolve_manual_play = AsyncMock(return_value=mock_play_result)
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["submit_manual_outcome"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"outcome": "groundball_c",
|
|
"hit_location": "SS",
|
|
},
|
|
)
|
|
|
|
assert lock_acquired, "Handler should acquire game lock"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_outcome_timeout_returns_error(self, mock_manager):
|
|
"""
|
|
Verify submit_manual_outcome returns error on lock timeout.
|
|
|
|
Same as roll_dice - should emit 'server busy' message.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
@asynccontextmanager
|
|
async def timeout_lock(game_id, timeout=30.0):
|
|
raise asyncio.TimeoutError(f"Lock timeout for game {game_id}")
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_game_state = MagicMock()
|
|
mock_game_state.game_id = uuid4()
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = timeout_lock
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["submit_manual_outcome"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(uuid4()),
|
|
"outcome": "groundball_c",
|
|
},
|
|
)
|
|
|
|
mock_manager.emit_to_user.assert_called_once()
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "error"
|
|
assert "busy" in call_args[0][2]["message"].lower()
|
|
|
|
|
|
# ============================================================================
|
|
# SUBSTITUTION HANDLER LOCKING TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestSubstitutionHandlerLocking:
|
|
"""Tests for locking behavior in substitution handlers."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pinch_hitter_acquires_lock(
|
|
self, mock_manager, mock_game_state, mock_substitution_result_success
|
|
):
|
|
"""
|
|
Verify request_pinch_hitter acquires lock before substitution.
|
|
|
|
Substitutions must be serialized to prevent duplicate substitution
|
|
requests corrupting lineup state.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
lock_acquired = False
|
|
|
|
@asynccontextmanager
|
|
async def tracking_lock(game_id, timeout=30.0):
|
|
nonlocal lock_acquired
|
|
lock_acquired = True
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = tracking_lock
|
|
|
|
mock_sub_instance = MagicMock()
|
|
mock_sub_instance.pinch_hit = AsyncMock(
|
|
return_value=mock_substitution_result_success
|
|
)
|
|
MockSubMgr.return_value = mock_sub_instance
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pinch_hitter"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
assert lock_acquired, "Pinch hitter should acquire game lock"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_acquires_lock(
|
|
self, mock_manager, mock_game_state, mock_substitution_result_success
|
|
):
|
|
"""Verify request_defensive_replacement acquires lock."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
lock_acquired = False
|
|
|
|
@asynccontextmanager
|
|
async def tracking_lock(game_id, timeout=30.0):
|
|
nonlocal lock_acquired
|
|
lock_acquired = True
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = tracking_lock
|
|
|
|
mock_sub_instance = MagicMock()
|
|
mock_sub_instance.defensive_replace = AsyncMock(
|
|
return_value=mock_substitution_result_success
|
|
)
|
|
MockSubMgr.return_value = mock_sub_instance
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"new_position": "CF",
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
assert lock_acquired, "Defensive replacement should acquire game lock"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pitching_change_acquires_lock(
|
|
self, mock_manager, mock_game_state, mock_substitution_result_success
|
|
):
|
|
"""Verify request_pitching_change acquires lock."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
lock_acquired = False
|
|
|
|
@asynccontextmanager
|
|
async def tracking_lock(game_id, timeout=30.0):
|
|
nonlocal lock_acquired
|
|
lock_acquired = True
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = tracking_lock
|
|
|
|
mock_sub_instance = MagicMock()
|
|
mock_sub_instance.change_pitcher = AsyncMock(
|
|
return_value=mock_substitution_result_success
|
|
)
|
|
MockSubMgr.return_value = mock_sub_instance
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pitching_change"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
assert lock_acquired, "Pitching change should acquire game lock"
|
|
|
|
|
|
# ============================================================================
|
|
# CONCURRENT ACCESS TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestConcurrentAccess:
|
|
"""Tests for behavior under concurrent access scenarios."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_games_do_not_block(self, mock_manager, mock_ab_roll):
|
|
"""
|
|
Verify that handlers for different games do not block each other.
|
|
|
|
Operations on game_1 should not wait for operations on game_2.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
game_1_id = uuid4()
|
|
game_2_id = uuid4()
|
|
locks_acquired = {game_1_id: False, game_2_id: False}
|
|
|
|
@asynccontextmanager
|
|
async def tracking_lock(game_id, timeout=30.0):
|
|
locks_acquired[game_id] = True
|
|
yield
|
|
|
|
mock_state_1 = MagicMock(game_id=game_1_id, league_id="sba")
|
|
mock_state_2 = MagicMock(game_id=game_2_id, league_id="sba")
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.dice_system") as mock_dice:
|
|
|
|
def get_state(gid):
|
|
if gid == game_1_id:
|
|
return mock_state_1
|
|
elif gid == game_2_id:
|
|
return mock_state_2
|
|
return None
|
|
|
|
mock_state_mgr.get_state.side_effect = get_state
|
|
mock_state_mgr.update_state = MagicMock()
|
|
mock_state_mgr.game_lock = tracking_lock
|
|
mock_dice.roll_ab.return_value = mock_ab_roll
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["roll_dice"]
|
|
|
|
# Call handlers for both games
|
|
await handler("sid_1", {"game_id": str(game_1_id)})
|
|
await handler("sid_2", {"game_id": str(game_2_id)})
|
|
|
|
# Both should have acquired their own locks
|
|
assert locks_acquired[game_1_id], "Game 1 should acquire its lock"
|
|
assert locks_acquired[game_2_id], "Game 2 should acquire its lock"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lock_timeout_is_30_seconds(self, mock_manager, mock_game_state):
|
|
"""
|
|
Verify that the default lock timeout is 30 seconds.
|
|
|
|
The game_lock context manager should be called with timeout=30.0.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
timeout_used = None
|
|
|
|
@asynccontextmanager
|
|
async def tracking_lock(game_id, timeout=30.0):
|
|
nonlocal timeout_used
|
|
timeout_used = timeout
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.dice_system") as mock_dice:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.update_state = MagicMock()
|
|
mock_state_mgr.game_lock = tracking_lock
|
|
mock_dice.roll_ab.return_value = MagicMock()
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["roll_dice"]
|
|
|
|
await handler("test_sid", {"game_id": str(mock_game_state.game_id)})
|
|
|
|
# The lock uses default timeout, which should be 30.0
|
|
# Note: If timeout is not explicitly passed, it uses the default
|
|
assert timeout_used == 30.0, f"Expected 30.0s timeout, got {timeout_used}"
|