mantimon-tcg/backend/tests/unit/services/test_game_state_manager.py
Cal Corum 531d3e1e79 Implement GameService.end_game with history archival (GS-005)
- Add GameEndResult dataclass with winner, loser, final views, duration
- Add _map_end_reason() to map core GameEndReason to DB EndReason
  (raises ValueError for unknown reasons to catch missing enum sync)
- Enhance end_game() to build replay data and return comprehensive result
- Add archive_to_history() to GameStateManager for complete game archival:
  - Creates GameHistory record with replay data
  - Deletes ActiveGame record
  - Clears Redis cache
  - All in single transaction
- Add ArchiveResult dataclass for archive operation metadata
- Add TODO for session_factory DI refactor in GameStateManager
- Update tests: 5 new end_game tests, 6 new archive_to_history tests

Phase 4 progress: 10/18 tasks complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 20:10:27 -06:00

372 lines
14 KiB
Python

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