"""Tests for GameNamespaceHandler. This module tests the WebSocket event handlers for the /game namespace. All tests use dependency injection to mock GameService and ConnectionManager, following the project's no-monkey-patching testing pattern. """ from unittest.mock import AsyncMock import pytest from app.core.enums import GameEndReason, TurnPhase from app.core.models.game_state import GameState, PlayerState from app.core.visibility import VisibleGameState from app.schemas.ws_messages import ConnectionStatus, WSErrorCode from app.services.connection_manager import ConnectionInfo from app.services.game_service import ( ForcedActionRequiredError, GameActionResult, GameAlreadyEndedError, GameEndResult, GameJoinResult, GameNotFoundError, InvalidActionError, NotPlayerTurnError, PendingForcedAction, ) from app.socketio.game_namespace import GameNamespaceHandler @pytest.fixture def mock_game_service() -> AsyncMock: """Create a mock GameService. The GameService orchestrates game operations - join, execute action, resign, end game, and state retrieval. """ service = AsyncMock() service.join_game = AsyncMock() service.execute_action = AsyncMock() service.resign_game = AsyncMock() service.end_game = AsyncMock() service.get_game_state = AsyncMock() return service @pytest.fixture def mock_connection_manager() -> AsyncMock: """Create a mock ConnectionManager. The ConnectionManager tracks WebSocket connections and their association with games. """ cm = AsyncMock() cm.join_game = AsyncMock(return_value=True) cm.leave_game = AsyncMock() cm.get_connection = AsyncMock(return_value=None) cm.get_game_user_sids = AsyncMock(return_value={}) cm.get_opponent_sid = AsyncMock(return_value=None) cm.get_user_active_game = AsyncMock(return_value=None) return cm @pytest.fixture def mock_state_manager() -> AsyncMock: """Create a mock GameStateManager. The GameStateManager handles persistence to Redis/Postgres and provides methods to look up active games. """ sm = AsyncMock() sm.get_player_active_games = AsyncMock(return_value=[]) sm.load_state = AsyncMock(return_value=None) sm.save_to_cache = AsyncMock() sm.persist_to_db = AsyncMock() return sm @pytest.fixture def mock_sio() -> AsyncMock: """Create a mock Socket.IO AsyncServer. The server is used to emit events and manage rooms. """ sio = AsyncMock() sio.emit = AsyncMock() sio.enter_room = AsyncMock() sio.leave_room = AsyncMock() return sio @pytest.fixture def handler( mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_state_manager: AsyncMock, ) -> GameNamespaceHandler: """Create a GameNamespaceHandler with injected mock dependencies.""" return GameNamespaceHandler( game_svc=mock_game_service, conn_manager=mock_connection_manager, state_manager=mock_state_manager, ) @pytest.fixture def sample_visible_state() -> VisibleGameState: """Create a sample visible game state for testing. This represents what a player would see after joining a game. """ from app.core.visibility import VisiblePlayerState, VisibleZone return VisibleGameState( game_id="game-123", viewer_id="player-1", players={ "player-1": VisiblePlayerState( player_id="player-1", is_current_player=True, deck_count=40, hand=VisibleZone(count=7, cards=[], zone_type="hand"), ), "player-2": VisiblePlayerState( player_id="player-2", is_current_player=False, deck_count=40, hand=VisibleZone(count=7, cards=[], zone_type="hand"), ), }, current_player_id="player-1", turn_number=1, phase=TurnPhase.MAIN, is_my_turn=True, ) @pytest.fixture def sample_game_state() -> GameState: """Create a sample game state for testing broadcasts.""" player1 = PlayerState(player_id="player-1") player2 = PlayerState(player_id="player-2") return GameState( game_id="game-123", players={"player-1": player1, "player-2": player2}, current_player_id="player-1", turn_number=1, phase=TurnPhase.MAIN, ) class TestHandleJoin: """Tests for the handle_join event handler.""" @pytest.mark.asyncio async def test_join_success( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, sample_visible_state: VisibleGameState, ) -> None: """Test successful game join returns game state. When a player joins a game they're a participant in, they should receive the filtered game state and be added to the game room. """ mock_game_service.join_game.return_value = GameJoinResult( success=True, game_id="game-123", player_id="player-1", visible_state=sample_visible_state, is_your_turn=True, game_over=False, ) result = await handler.handle_join( mock_sio, "sid-123", "player-1", {"game_id": "game-123", "message_id": "msg-1"}, ) assert result["success"] is True assert result["game_id"] == "game-123" assert result["is_your_turn"] is True assert "state" in result # Should have joined the connection manager mock_connection_manager.join_game.assert_called_once_with("sid-123", "game-123") # Should have entered the Socket.IO room mock_sio.enter_room.assert_called_once_with("sid-123", "game:game-123", namespace="/game") @pytest.mark.asyncio async def test_join_missing_game_id( self, handler: GameNamespaceHandler, mock_sio: AsyncMock, ) -> None: """Test that missing game_id returns error. The game_id is required to join a game. """ result = await handler.handle_join( mock_sio, "sid-123", "player-1", {"message_id": "msg-1"}, # No game_id ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value assert "game_id" in result["error"]["message"].lower() @pytest.mark.asyncio async def test_join_game_not_found( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test joining non-existent game returns error. When GameService indicates the game wasn't found, we should return a game_not_found error. """ mock_game_service.join_game.return_value = GameJoinResult( success=False, game_id="nonexistent", player_id="player-1", message="Game not found", ) result = await handler.handle_join( mock_sio, "sid-123", "player-1", {"game_id": "nonexistent"}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value @pytest.mark.asyncio async def test_join_not_participant( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test joining as non-participant returns error. Only players in the game can join it. """ mock_game_service.join_game.return_value = GameJoinResult( success=False, game_id="game-123", player_id="stranger", message="You are not a participant in this game", ) result = await handler.handle_join( mock_sio, "sid-123", "stranger", {"game_id": "game-123"}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.NOT_IN_GAME.value @pytest.mark.asyncio async def test_join_includes_pending_forced_action( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, sample_visible_state: VisibleGameState, ) -> None: """Test that pending forced action is included in join result. When rejoining a game with a pending forced action, the client needs to know what action is required. """ mock_game_service.join_game.return_value = GameJoinResult( success=True, game_id="game-123", player_id="player-1", visible_state=sample_visible_state, is_your_turn=True, pending_forced_action=PendingForcedAction( player_id="player-1", action_type="select_active", reason="Your active Pokemon was knocked out.", params={"available_bench_ids": ["bench-1"]}, ), ) result = await handler.handle_join( mock_sio, "sid-123", "player-1", {"game_id": "game-123"}, ) assert result["success"] is True assert "pending_forced_action" in result assert result["pending_forced_action"]["action_type"] == "select_active" @pytest.mark.asyncio async def test_join_notifies_opponent( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, sample_visible_state: VisibleGameState, ) -> None: """Test that joining notifies the opponent. When a player joins/rejoins, the opponent should be notified of their connection status. """ mock_game_service.join_game.return_value = GameJoinResult( success=True, game_id="game-123", player_id="player-1", visible_state=sample_visible_state, is_your_turn=True, ) mock_connection_manager.get_opponent_sid.return_value = "opponent-sid" await handler.handle_join( mock_sio, "sid-123", "player-1", {"game_id": "game-123"}, ) # Should have tried to notify opponent mock_connection_manager.get_opponent_sid.assert_called() # Should have emitted opponent status to opponent emit_calls = [ call for call in mock_sio.emit.call_args_list if call[0][0] == "game:opponent_status" ] assert len(emit_calls) == 1 class TestHandleAction: """Tests for the handle_action event handler.""" @pytest.mark.asyncio async def test_action_success( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, sample_game_state: GameState, ) -> None: """Test successful action execution. A valid action should be executed and the state should be broadcast to all participants. """ mock_game_service.execute_action.return_value = GameActionResult( success=True, game_id="game-123", action_type="pass", message="Turn ended", turn_changed=True, current_player_id="player-2", ) mock_game_service.get_game_state.return_value = sample_game_state mock_connection_manager.get_game_user_sids.return_value = { "player-1": "sid-1", "player-2": "sid-2", } result = await handler.handle_action( mock_sio, "sid-123", "player-1", { "game_id": "game-123", "action": {"type": "pass"}, "message_id": "msg-1", }, ) assert result["success"] is True assert result["action_type"] == "pass" assert result["turn_changed"] is True # Should have broadcast state to participants assert mock_sio.emit.called @pytest.mark.asyncio async def test_action_missing_game_id( self, handler: GameNamespaceHandler, mock_sio: AsyncMock, ) -> None: """Test that missing game_id returns error.""" result = await handler.handle_action( mock_sio, "sid-123", "player-1", {"action": {"type": "pass"}}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value @pytest.mark.asyncio async def test_action_missing_action( self, handler: GameNamespaceHandler, mock_sio: AsyncMock, ) -> None: """Test that missing action returns error.""" result = await handler.handle_action( mock_sio, "sid-123", "player-1", {"game_id": "game-123"}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value @pytest.mark.asyncio async def test_action_invalid_action_format( self, handler: GameNamespaceHandler, mock_sio: AsyncMock, ) -> None: """Test that invalid action format returns error. The action data must match a valid Action type. """ result = await handler.handle_action( mock_sio, "sid-123", "player-1", {"game_id": "game-123", "action": {"type": "invalid_action_type"}}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.INVALID_ACTION.value @pytest.mark.asyncio async def test_action_game_not_found( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test action on non-existent game returns error.""" mock_game_service.execute_action.side_effect = GameNotFoundError("game-123") result = await handler.handle_action( mock_sio, "sid-123", "player-1", {"game_id": "game-123", "action": {"type": "pass"}}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value @pytest.mark.asyncio async def test_action_not_your_turn( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test action when not player's turn returns error.""" mock_game_service.execute_action.side_effect = NotPlayerTurnError( "game-123", "player-2", "player-1" ) result = await handler.handle_action( mock_sio, "sid-123", "player-2", {"game_id": "game-123", "action": {"type": "pass"}}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.NOT_YOUR_TURN.value @pytest.mark.asyncio async def test_action_forced_action_required( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test action when forced action required returns error.""" mock_game_service.execute_action.side_effect = ForcedActionRequiredError( "game-123", "player-1", "select_active", "pass" ) result = await handler.handle_action( mock_sio, "sid-123", "player-1", {"game_id": "game-123", "action": {"type": "pass"}}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.ACTION_NOT_ALLOWED.value @pytest.mark.asyncio async def test_action_invalid_action( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test invalid action (rejected by engine) returns error.""" mock_game_service.execute_action.side_effect = InvalidActionError( "game-123", "player-1", "Not enough energy" ) result = await handler.handle_action( mock_sio, "sid-123", "player-1", {"game_id": "game-123", "action": {"type": "attack", "attack_index": 0}}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.INVALID_ACTION.value assert "Not enough energy" in result["error"]["message"] @pytest.mark.asyncio async def test_action_game_over( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, sample_game_state: GameState, ) -> None: """Test action that ends game broadcasts game over. When an action results in game over, the result should include game_over=True and the game should be ended. """ mock_game_service.execute_action.return_value = GameActionResult( success=True, game_id="game-123", action_type="attack", game_over=True, winner_id="player-1", end_reason=GameEndReason.PRIZES_TAKEN, ) mock_game_service.get_game_state.return_value = sample_game_state mock_game_service.end_game.return_value = GameEndResult( success=True, game_id="game-123", winner_id="player-1", ) mock_connection_manager.get_game_user_sids.return_value = {} result = await handler.handle_action( mock_sio, "sid-123", "player-1", {"game_id": "game-123", "action": {"type": "attack", "attack_index": 0}}, ) assert result["success"] is True assert result["game_over"] is True assert result["winner_id"] == "player-1" # Should have called end_game mock_game_service.end_game.assert_called_once() @pytest.mark.asyncio async def test_action_includes_pending_forced_action( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, sample_game_state: GameState, ) -> None: """Test that action result includes pending forced action. When an action creates a forced action (e.g., knockout triggers select_active), the result should include it. """ mock_game_service.execute_action.return_value = GameActionResult( success=True, game_id="game-123", action_type="attack", pending_forced_action=PendingForcedAction( player_id="player-2", action_type="select_active", reason="Your active was knocked out.", ), ) mock_game_service.get_game_state.return_value = sample_game_state mock_connection_manager.get_game_user_sids.return_value = {} result = await handler.handle_action( mock_sio, "sid-123", "player-1", {"game_id": "game-123", "action": {"type": "attack", "attack_index": 0}}, ) assert result["success"] is True assert "pending_forced_action" in result assert result["pending_forced_action"]["action_type"] == "select_active" class TestHandleResign: """Tests for the handle_resign event handler.""" @pytest.mark.asyncio async def test_resign_success( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, sample_game_state: GameState, ) -> None: """Test successful resignation. Resignation should end the game with the opponent as winner. """ mock_game_service.resign_game.return_value = GameActionResult( success=True, game_id="game-123", action_type="resign", game_over=True, winner_id="player-2", end_reason=GameEndReason.RESIGNATION, ) mock_game_service.get_game_state.return_value = sample_game_state mock_game_service.end_game.return_value = GameEndResult( success=True, game_id="game-123", winner_id="player-2", ) mock_connection_manager.get_game_user_sids.return_value = {} result = await handler.handle_resign( mock_sio, "sid-123", "player-1", {"game_id": "game-123"}, ) assert result["success"] is True assert result["game_over"] is True assert result["winner_id"] == "player-2" # Should have called end_game mock_game_service.end_game.assert_called_once() @pytest.mark.asyncio async def test_resign_missing_game_id( self, handler: GameNamespaceHandler, mock_sio: AsyncMock, ) -> None: """Test resignation with missing game_id returns error.""" result = await handler.handle_resign( mock_sio, "sid-123", "player-1", {}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value @pytest.mark.asyncio async def test_resign_game_not_found( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test resignation from non-existent game returns error.""" mock_game_service.resign_game.side_effect = GameNotFoundError("game-123") result = await handler.handle_resign( mock_sio, "sid-123", "player-1", {"game_id": "game-123"}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value @pytest.mark.asyncio async def test_resign_game_already_ended( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test resignation from ended game returns error.""" mock_game_service.resign_game.side_effect = GameAlreadyEndedError("game-123") result = await handler.handle_resign( mock_sio, "sid-123", "player-1", {"game_id": "game-123"}, ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.GAME_ENDED.value class TestHandleDisconnect: """Tests for the handle_disconnect event handler.""" @pytest.mark.asyncio async def test_disconnect_notifies_opponent( self, handler: GameNamespaceHandler, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test that disconnect notifies opponent. When a player disconnects from a game, the opponent should be notified of the disconnection. """ from datetime import UTC, datetime mock_connection_manager.get_connection.return_value = ConnectionInfo( sid="sid-123", user_id="player-1", game_id="game-123", connected_at=datetime.now(UTC), last_seen=datetime.now(UTC), ) mock_connection_manager.get_opponent_sid.return_value = "opponent-sid" await handler.handle_disconnect(mock_sio, "sid-123", "player-1") # Should have looked up connection to find game mock_connection_manager.get_connection.assert_called_once_with("sid-123") # Should have emitted opponent status to opponent emit_calls = [ call for call in mock_sio.emit.call_args_list if call[0][0] == "game:opponent_status" ] assert len(emit_calls) == 1 assert emit_calls[0][1]["to"] == "opponent-sid" @pytest.mark.asyncio async def test_disconnect_no_game( self, handler: GameNamespaceHandler, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test disconnect when not in a game does nothing. If the player wasn't in a game, there's no opponent to notify. """ mock_connection_manager.get_connection.return_value = None await handler.handle_disconnect(mock_sio, "sid-123", "player-1") # Should not emit anything mock_sio.emit.assert_not_called() @pytest.mark.asyncio async def test_disconnect_no_game_id_in_connection( self, handler: GameNamespaceHandler, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test disconnect when connection exists but not in game. The connection may exist but game_id may be None if they haven't joined a game yet. """ from datetime import UTC, datetime mock_connection_manager.get_connection.return_value = ConnectionInfo( sid="sid-123", user_id="player-1", game_id=None, # Not in a game connected_at=datetime.now(UTC), last_seen=datetime.now(UTC), ) await handler.handle_disconnect(mock_sio, "sid-123", "player-1") # Should not emit anything mock_sio.emit.assert_not_called() class TestBroadcastGameState: """Tests for the _broadcast_game_state helper method.""" @pytest.mark.asyncio async def test_broadcast_sends_to_each_player( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, sample_game_state: GameState, ) -> None: """Test that broadcast sends filtered state to each player. Each player should receive their own visibility-filtered view of the game state. """ mock_game_service.get_game_state.return_value = sample_game_state mock_connection_manager.get_game_user_sids.return_value = { "player-1": "sid-1", "player-2": "sid-2", } await handler._broadcast_game_state(mock_sio, "game-123") # Should have emitted to both players assert mock_sio.emit.call_count == 2 # Verify each emission was to a different player emit_calls = mock_sio.emit.call_args_list to_sids = {call[1]["to"] for call in emit_calls} assert to_sids == {"sid-1", "sid-2"} # All emissions should be game:state events for call in emit_calls: assert call[0][0] == "game:state" @pytest.mark.asyncio async def test_broadcast_handles_game_not_found( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test that broadcast handles missing game gracefully. If the game can't be found (e.g., already archived), the broadcast should log a warning but not raise an error. """ mock_game_service.get_game_state.side_effect = GameNotFoundError("game-123") # Should not raise await handler._broadcast_game_state(mock_sio, "game-123") # Should not have emitted anything mock_sio.emit.assert_not_called() class TestNotifyOpponentStatus: """Tests for the _notify_opponent_status helper method.""" @pytest.mark.asyncio async def test_notify_sends_to_opponent( self, handler: GameNamespaceHandler, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test that notify sends status to opponent. The opponent's connection should receive a status update about the player's connection state. """ mock_connection_manager.get_opponent_sid.return_value = "opponent-sid" await handler._notify_opponent_status( mock_sio, "sender-sid", "game-123", "player-1", ConnectionStatus.CONNECTED, ) mock_sio.emit.assert_called_once() call = mock_sio.emit.call_args assert call[0][0] == "game:opponent_status" assert call[1]["to"] == "opponent-sid" message = call[0][1] assert message["opponent_id"] == "player-1" assert message["status"] == ConnectionStatus.CONNECTED.value @pytest.mark.asyncio async def test_notify_no_opponent_connected( self, handler: GameNamespaceHandler, mock_connection_manager: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test that notify does nothing when opponent not connected. If the opponent isn't connected, we can't notify them. """ mock_connection_manager.get_opponent_sid.return_value = None await handler._notify_opponent_status( mock_sio, "sender-sid", "game-123", "player-1", ConnectionStatus.CONNECTED, ) mock_sio.emit.assert_not_called() class TestErrorResponse: """Tests for the _error_response helper method.""" def test_error_response_format( self, handler: GameNamespaceHandler, ) -> None: """Test that error response has correct format. Error responses should include success=False, error code, message, and the original request's message_id. """ result = handler._error_response( WSErrorCode.GAME_NOT_FOUND, "Game not found", "msg-123", ) assert result["success"] is False assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value assert result["error"]["message"] == "Game not found" assert result["request_message_id"] == "msg-123" class TestHandleReconnect: """Tests for the handle_reconnect event handler. The reconnection handler is called after successful authentication to check if the user has an active game and auto-rejoin them. """ @pytest.fixture def mock_active_game(self) -> AsyncMock: """Create a mock ActiveGame record. ActiveGame represents a game in progress stored in Postgres. """ from datetime import UTC, datetime from uuid import UUID game = AsyncMock() game.id = UUID("12345678-1234-5678-1234-567812345678") game.started_at = datetime.now(UTC) game.last_action_at = datetime.now(UTC) return game @pytest.mark.asyncio async def test_reconnect_no_active_games( self, handler: GameNamespaceHandler, mock_state_manager: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test that reconnect returns None when user has no active games. If the user isn't in any games, there's nothing to reconnect to. """ mock_state_manager.get_player_active_games.return_value = [] result = await handler.handle_reconnect( mock_sio, "sid-123", "12345678-1234-5678-1234-567812345678", ) assert result is None mock_state_manager.get_player_active_games.assert_called_once() @pytest.mark.asyncio async def test_reconnect_invalid_uuid_returns_none( self, handler: GameNamespaceHandler, mock_state_manager: AsyncMock, mock_sio: AsyncMock, ) -> None: """Test that invalid user_id (non-UUID) returns None. NPC IDs are not valid UUIDs and shouldn't have active games. """ result = await handler.handle_reconnect( mock_sio, "sid-123", "npc-grass-trainer-1", # Not a valid UUID ) assert result is None # Should not have queried for active games mock_state_manager.get_player_active_games.assert_not_called() @pytest.mark.asyncio async def test_reconnect_success( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_state_manager: AsyncMock, mock_sio: AsyncMock, mock_active_game: AsyncMock, sample_visible_state: VisibleGameState, ) -> None: """Test successful reconnection to an active game. When a user connects with an active game, they should be automatically rejoined to that game. """ mock_state_manager.get_player_active_games.return_value = [mock_active_game] mock_game_service.join_game.return_value = GameJoinResult( success=True, game_id=str(mock_active_game.id), player_id="12345678-1234-5678-1234-567812345678", visible_state=sample_visible_state, is_your_turn=True, game_over=False, ) result = await handler.handle_reconnect( mock_sio, "sid-123", "12345678-1234-5678-1234-567812345678", ) assert result is not None assert result["game_id"] == str(mock_active_game.id) assert result["is_your_turn"] is True assert "state" in result # Should have joined connection manager mock_connection_manager.join_game.assert_called_once_with( "sid-123", str(mock_active_game.id) ) # Should have entered the room mock_sio.enter_room.assert_called_once() @pytest.mark.asyncio async def test_reconnect_notifies_opponent( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_state_manager: AsyncMock, mock_sio: AsyncMock, mock_active_game: AsyncMock, sample_visible_state: VisibleGameState, ) -> None: """Test that reconnection notifies the opponent. When a player reconnects, their opponent should be notified that they're back online. """ mock_state_manager.get_player_active_games.return_value = [mock_active_game] mock_game_service.join_game.return_value = GameJoinResult( success=True, game_id=str(mock_active_game.id), player_id="12345678-1234-5678-1234-567812345678", visible_state=sample_visible_state, is_your_turn=True, ) mock_connection_manager.get_opponent_sid.return_value = "opponent-sid" await handler.handle_reconnect( mock_sio, "sid-123", "12345678-1234-5678-1234-567812345678", ) # Should have tried to notify opponent mock_connection_manager.get_opponent_sid.assert_called() # Should have emitted opponent status emit_calls = [ call for call in mock_sio.emit.call_args_list if call[0][0] == "game:opponent_status" ] assert len(emit_calls) == 1 @pytest.mark.asyncio async def test_reconnect_includes_pending_forced_action( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_state_manager: AsyncMock, mock_sio: AsyncMock, mock_active_game: AsyncMock, sample_visible_state: VisibleGameState, ) -> None: """Test that reconnect includes pending forced action. If the game has a pending forced action when the player reconnects, it should be included in the response. """ mock_state_manager.get_player_active_games.return_value = [mock_active_game] mock_game_service.join_game.return_value = GameJoinResult( success=True, game_id=str(mock_active_game.id), player_id="12345678-1234-5678-1234-567812345678", visible_state=sample_visible_state, is_your_turn=True, pending_forced_action=PendingForcedAction( player_id="12345678-1234-5678-1234-567812345678", action_type="select_active", reason="Your active Pokemon was knocked out.", params={"available_bench_ids": ["bench-1"]}, ), ) result = await handler.handle_reconnect( mock_sio, "sid-123", "12345678-1234-5678-1234-567812345678", ) assert result is not None assert "pending_forced_action" in result assert result["pending_forced_action"]["action_type"] == "select_active" @pytest.mark.asyncio async def test_reconnect_includes_turn_timer( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_state_manager: AsyncMock, mock_sio: AsyncMock, mock_active_game: AsyncMock, sample_visible_state: VisibleGameState, ) -> None: """Test that reconnect includes turn timer information. If turn timers are enabled, the remaining time should be included in the reconnection response. """ mock_state_manager.get_player_active_games.return_value = [mock_active_game] mock_game_service.join_game.return_value = GameJoinResult( success=True, game_id=str(mock_active_game.id), player_id="12345678-1234-5678-1234-567812345678", visible_state=sample_visible_state, is_your_turn=True, turn_timeout_seconds=120, turn_deadline=1700000000.0, ) result = await handler.handle_reconnect( mock_sio, "sid-123", "12345678-1234-5678-1234-567812345678", ) assert result is not None assert result["turn_timeout_seconds"] == 120 assert result["turn_deadline"] == 1700000000.0 @pytest.mark.asyncio async def test_reconnect_join_game_fails( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_state_manager: AsyncMock, mock_sio: AsyncMock, mock_active_game: AsyncMock, ) -> None: """Test that reconnect returns None when join_game fails. If the GameService fails to join the game (e.g., game was archived between lookup and join), we should return None gracefully. """ mock_state_manager.get_player_active_games.return_value = [mock_active_game] mock_game_service.join_game.return_value = GameJoinResult( success=False, game_id=str(mock_active_game.id), player_id="12345678-1234-5678-1234-567812345678", message="Game not found", ) result = await handler.handle_reconnect( mock_sio, "sid-123", "12345678-1234-5678-1234-567812345678", ) assert result is None @pytest.mark.asyncio async def test_reconnect_multiple_games_uses_most_recent( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_state_manager: AsyncMock, mock_sio: AsyncMock, sample_visible_state: VisibleGameState, ) -> None: """Test that with multiple active games, the most recent is used. If a player has multiple active games (edge case), we should reconnect to the one with the most recent activity. """ from datetime import UTC, datetime, timedelta from uuid import UUID older_game = AsyncMock() older_game.id = UUID("11111111-1111-1111-1111-111111111111") older_game.started_at = datetime.now(UTC) - timedelta(hours=2) older_game.last_action_at = datetime.now(UTC) - timedelta(hours=1) newer_game = AsyncMock() newer_game.id = UUID("22222222-2222-2222-2222-222222222222") newer_game.started_at = datetime.now(UTC) - timedelta(hours=1) newer_game.last_action_at = datetime.now(UTC) - timedelta(minutes=5) # Return older game first to verify sorting works mock_state_manager.get_player_active_games.return_value = [older_game, newer_game] mock_game_service.join_game.return_value = GameJoinResult( success=True, game_id=str(newer_game.id), player_id="12345678-1234-5678-1234-567812345678", visible_state=sample_visible_state, is_your_turn=True, ) result = await handler.handle_reconnect( mock_sio, "sid-123", "12345678-1234-5678-1234-567812345678", ) assert result is not None # Should have joined the newer game assert result["game_id"] == str(newer_game.id) @pytest.mark.asyncio async def test_reconnect_to_ended_game( self, handler: GameNamespaceHandler, mock_game_service: AsyncMock, mock_connection_manager: AsyncMock, mock_state_manager: AsyncMock, mock_sio: AsyncMock, mock_active_game: AsyncMock, sample_visible_state: VisibleGameState, ) -> None: """Test reconnection to a game that has ended. If the game ended while the player was disconnected, they should still see the final state with game_over=True. """ mock_state_manager.get_player_active_games.return_value = [mock_active_game] mock_game_service.join_game.return_value = GameJoinResult( success=True, game_id=str(mock_active_game.id), player_id="12345678-1234-5678-1234-567812345678", visible_state=sample_visible_state, is_your_turn=False, game_over=True, message="Game has ended", ) result = await handler.handle_reconnect( mock_sio, "sid-123", "12345678-1234-5678-1234-567812345678", ) assert result is not None assert result["game_over"] is True