"""Tests for GameStateManager. This module tests the game state persistence layer that manages the Redis-primary, Postgres-backup storage strategy. """ from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID, uuid4 import pytest from app.core.enums import TurnPhase from app.core.models.game_state import GameState, PlayerState from app.db.models.game import EndReason, GameType from app.services.game_state_manager import ArchiveResult, GameStateManager @pytest.fixture def mock_redis() -> AsyncMock: """Create a mock RedisHelper. The RedisHelper handles cache operations for game state. """ redis = AsyncMock() redis.set_json = AsyncMock() redis.get_json = AsyncMock(return_value=None) redis.delete = AsyncMock(return_value=True) redis.exists = AsyncMock(return_value=False) return redis @pytest.fixture def game_state_manager(mock_redis: AsyncMock) -> GameStateManager: """Create a GameStateManager with mock Redis.""" return GameStateManager(redis=mock_redis) @pytest.fixture def sample_game_state() -> GameState: """Create a sample game state for testing.""" 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=10, phase=TurnPhase.MAIN, action_log=[{"type": "attack", "index": 0}], ) class TestArchiveToHistory: """Tests for the archive_to_history method. This method handles the complete game ending process: - Creates GameHistory record - Deletes ActiveGame record - Clears Redis cache """ @pytest.mark.asyncio async def test_archive_to_history_returns_result( self, game_state_manager: GameStateManager, sample_game_state: GameState, ) -> None: """Test that archive_to_history returns ArchiveResult. The result should contain game metadata and success status. """ from app.db.models.game import ActiveGame game_id = str(uuid4()) sample_game_state.game_id = game_id player1_id = uuid4() player2_id = uuid4() # Create mock ActiveGame mock_active_game = MagicMock(spec=ActiveGame) mock_active_game.id = UUID(game_id) mock_active_game.game_type = GameType.FREEPLAY mock_active_game.player1_id = player1_id mock_active_game.player2_id = player2_id mock_active_game.npc_id = None mock_active_game.started_at = datetime.now(UTC) # Mock database session mock_session = AsyncMock() mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_active_game mock_session.execute = AsyncMock(return_value=mock_result) mock_session.add = MagicMock() mock_session.delete = AsyncMock() mock_session.flush = AsyncMock() mock_session.commit = AsyncMock() with patch("app.services.game_state_manager.get_session") as mock_get_session: mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None) result = await game_state_manager.archive_to_history( game_id=game_id, state=sample_game_state, winner_id=player1_id, winner_is_npc=False, end_reason=EndReason.PRIZES_TAKEN, replay_data={"action_log": sample_game_state.action_log}, ) assert isinstance(result, ArchiveResult) assert result.success is True assert result.game_id == game_id assert result.history_id == game_id assert result.turn_count == 10 assert result.game_type == GameType.FREEPLAY @pytest.mark.asyncio async def test_archive_to_history_creates_game_history( self, game_state_manager: GameStateManager, sample_game_state: GameState, ) -> None: """Test that archive_to_history creates a GameHistory record. The GameHistory record should be added to the session with all the required fields populated. """ from app.db.models.game import ActiveGame, GameHistory game_id = str(uuid4()) sample_game_state.game_id = game_id player1_id = uuid4() # Create mock ActiveGame mock_active_game = MagicMock(spec=ActiveGame) mock_active_game.id = UUID(game_id) mock_active_game.game_type = GameType.CAMPAIGN mock_active_game.player1_id = player1_id mock_active_game.player2_id = None mock_active_game.npc_id = "grass_trainer_1" mock_active_game.started_at = datetime.now(UTC) # Mock database session mock_session = AsyncMock() mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_active_game mock_session.execute = AsyncMock(return_value=mock_result) mock_session.add = MagicMock() mock_session.delete = AsyncMock() mock_session.flush = AsyncMock() mock_session.commit = AsyncMock() with patch("app.services.game_state_manager.get_session") as mock_get_session: mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None) await game_state_manager.archive_to_history( game_id=game_id, state=sample_game_state, winner_id=player1_id, winner_is_npc=False, end_reason=EndReason.RESIGNATION, replay_data={"action_log": []}, ) # Verify GameHistory was added mock_session.add.assert_called_once() added_obj = mock_session.add.call_args[0][0] assert isinstance(added_obj, GameHistory) assert added_obj.id == UUID(game_id) assert added_obj.game_type == GameType.CAMPAIGN assert added_obj.npc_id == "grass_trainer_1" assert added_obj.end_reason == EndReason.RESIGNATION @pytest.mark.asyncio async def test_archive_to_history_deletes_active_game( self, game_state_manager: GameStateManager, sample_game_state: GameState, ) -> None: """Test that archive_to_history deletes the ActiveGame record. After archiving to history, the game should be removed from the active games table. """ from app.db.models.game import ActiveGame game_id = str(uuid4()) sample_game_state.game_id = game_id # Create mock ActiveGame mock_active_game = MagicMock(spec=ActiveGame) mock_active_game.id = UUID(game_id) mock_active_game.game_type = GameType.FREEPLAY mock_active_game.player1_id = uuid4() mock_active_game.player2_id = uuid4() mock_active_game.npc_id = None mock_active_game.started_at = datetime.now(UTC) # Mock database session mock_session = AsyncMock() mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_active_game mock_session.execute = AsyncMock(return_value=mock_result) mock_session.add = MagicMock() mock_session.delete = AsyncMock() mock_session.flush = AsyncMock() mock_session.commit = AsyncMock() with patch("app.services.game_state_manager.get_session") as mock_get_session: mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None) await game_state_manager.archive_to_history( game_id=game_id, state=sample_game_state, winner_id=uuid4(), winner_is_npc=False, end_reason=EndReason.PRIZES_TAKEN, ) # Verify ActiveGame was deleted mock_session.delete.assert_called_once_with(mock_active_game) @pytest.mark.asyncio async def test_archive_to_history_deletes_from_cache( self, game_state_manager: GameStateManager, mock_redis: AsyncMock, sample_game_state: GameState, ) -> None: """Test that archive_to_history deletes from Redis cache. After archiving, the game state should be removed from the Redis cache to free up memory. """ from app.db.models.game import ActiveGame game_id = str(uuid4()) sample_game_state.game_id = game_id # Create mock ActiveGame mock_active_game = MagicMock(spec=ActiveGame) mock_active_game.id = UUID(game_id) mock_active_game.game_type = GameType.FREEPLAY mock_active_game.player1_id = uuid4() mock_active_game.player2_id = uuid4() mock_active_game.npc_id = None mock_active_game.started_at = datetime.now(UTC) # Mock database session mock_session = AsyncMock() mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_active_game mock_session.execute = AsyncMock(return_value=mock_result) mock_session.add = MagicMock() mock_session.delete = AsyncMock() mock_session.flush = AsyncMock() mock_session.commit = AsyncMock() with patch("app.services.game_state_manager.get_session") as mock_get_session: mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None) await game_state_manager.archive_to_history( game_id=game_id, state=sample_game_state, winner_id=uuid4(), winner_is_npc=False, end_reason=EndReason.TIMEOUT, ) # Verify Redis cache was deleted mock_redis.delete.assert_called_once_with(f"game:{game_id}") @pytest.mark.asyncio async def test_archive_to_history_handles_missing_active_game( self, game_state_manager: GameStateManager, sample_game_state: GameState, ) -> None: """Test that archive handles case when ActiveGame is not found. This shouldn't normally happen, but the method should handle it gracefully by using defaults from the game state. """ game_id = str(uuid4()) sample_game_state.game_id = game_id # Mock database session - ActiveGame not found mock_session = AsyncMock() mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = None # Not found mock_session.execute = AsyncMock(return_value=mock_result) mock_session.add = MagicMock() mock_session.flush = AsyncMock() mock_session.commit = AsyncMock() with patch("app.services.game_state_manager.get_session") as mock_get_session: mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None) result = await game_state_manager.archive_to_history( game_id=game_id, state=sample_game_state, winner_id=None, winner_is_npc=False, end_reason=EndReason.DRAW, ) # Should still succeed with defaults assert result.success is True assert result.game_type == GameType.FREEPLAY # Default assert result.duration_seconds == 0 # Default when no started_at @pytest.mark.asyncio async def test_archive_to_history_npc_winner( self, game_state_manager: GameStateManager, sample_game_state: GameState, ) -> None: """Test archiving when NPC is the winner. In campaign mode, the NPC can win. The winner_is_npc flag should be set correctly in GameHistory. """ from app.db.models.game import ActiveGame, GameHistory game_id = str(uuid4()) sample_game_state.game_id = game_id player1_id = uuid4() # Create mock ActiveGame for campaign mock_active_game = MagicMock(spec=ActiveGame) mock_active_game.id = UUID(game_id) mock_active_game.game_type = GameType.CAMPAIGN mock_active_game.player1_id = player1_id mock_active_game.player2_id = None mock_active_game.npc_id = "fire_leader" mock_active_game.started_at = datetime.now(UTC) # Mock database session mock_session = AsyncMock() mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_active_game mock_session.execute = AsyncMock(return_value=mock_result) mock_session.add = MagicMock() mock_session.delete = AsyncMock() mock_session.flush = AsyncMock() mock_session.commit = AsyncMock() with patch("app.services.game_state_manager.get_session") as mock_get_session: mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None) await game_state_manager.archive_to_history( game_id=game_id, state=sample_game_state, winner_id=None, # NPC won, so no UUID winner_is_npc=True, end_reason=EndReason.NO_POKEMON, ) # Verify GameHistory has NPC winner flag added_obj = mock_session.add.call_args[0][0] assert isinstance(added_obj, GameHistory) assert added_obj.winner_is_npc is True assert added_obj.winner_id is None