strat-gameplay-webapp/backend/tests/unit/websocket/test_handler_locking.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

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}"