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