mantimon-tcg/backend/tests/services/test_game_state_manager.py
Cal Corum 55e02ceb21 Replace silent error hiding with explicit failures
Three changes to fail fast instead of silently degrading:

1. GameService.create_game: Raise GameCreationError when energy card
   definition not found instead of logging warning and continuing.
   A deck with missing energy cards is fundamentally broken.

2. CardService.load_all: Collect all card file load failures and raise
   CardServiceLoadError at end with comprehensive error report. Prevents
   startup with partial card data that causes cryptic runtime errors.
   New exceptions: CardLoadError, CardServiceLoadError

3. GameStateManager.recover_active_games: Return RecoveryResult dataclass
   with recovered count, failed game IDs with error messages, and total.
   Enables proper monitoring and alerting for corrupted game state.

Tests added for energy card error case. Existing tests updated for
new RecoveryResult return type.

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

704 lines
23 KiB
Python

"""Tests for GameStateManager service.
This module tests the Redis-primary, Postgres-backup game state management:
- Cache operations (save/load/delete from Redis)
- Database operations (persist/load/delete from Postgres)
- High-level operations (load with fallback, recovery)
Prerequisites:
docker compose up -d # Postgres on 5433, Redis on 6380
"""
import json
from typing import Any
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.enums import TurnPhase
from app.core.models.game_state import GameState
from app.db.models import GameType
from app.db.redis import RedisHelper
from app.services.game_state_manager import (
GAME_KEY_PREFIX,
GameStateManager,
)
from tests.factories import UserFactory
from tests.services.conftest import create_test_game_state
# =============================================================================
# Mock Redis Helper for Unit Tests
# =============================================================================
class MockRedisHelper(RedisHelper):
"""In-memory mock Redis for unit testing without real Redis."""
def __init__(self) -> None:
self.store: dict[str, str] = {}
self.ttls: dict[str, int | None] = {}
async def get_json(self, key: str) -> dict[str, Any] | None:
"""Get JSON from mock store."""
value = self.store.get(key)
if value is None:
return None
return json.loads(value)
async def set_json(
self,
key: str,
value: dict[str, Any],
expire_seconds: int | None = None,
) -> None:
"""Store JSON in mock store."""
self.store[key] = json.dumps(value, default=str)
self.ttls[key] = expire_seconds
async def delete(self, key: str) -> bool:
"""Delete from mock store."""
if key in self.store:
del self.store[key]
self.ttls.pop(key, None)
return True
return False
async def exists(self, key: str) -> bool:
"""Check if key exists in mock store."""
return key in self.store
# =============================================================================
# Cache Operation Tests
# =============================================================================
class TestCacheOperations:
"""Tests for Redis cache operations."""
@pytest.mark.asyncio
async def test_save_to_cache(self, redis_client: Any) -> None:
"""Test saving game state to Redis cache.
Verifies that GameState is serialized to JSON and stored with
the correct key format.
"""
# Create a mock helper that uses our test redis client
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
game = create_test_game_state()
await manager.save_to_cache(game)
# Verify key exists
key = f"{GAME_KEY_PREFIX}{game.game_id}"
assert key in helper.store
# Verify data is valid JSON that can be deserialized
data = json.loads(helper.store[key])
assert data["game_id"] == game.game_id
assert data["turn_number"] == game.turn_number
@pytest.mark.asyncio
async def test_load_from_cache_hit(self) -> None:
"""Test loading game state from cache when it exists.
Verifies cache hit returns the correct GameState.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
game = create_test_game_state(turn_number=5)
# Save first
await manager.save_to_cache(game)
# Load and verify
loaded = await manager.load_from_cache(game.game_id)
assert loaded is not None
assert loaded.game_id == game.game_id
assert loaded.turn_number == 5
@pytest.mark.asyncio
async def test_load_from_cache_miss(self) -> None:
"""Test loading game state from cache when it doesn't exist.
Verifies cache miss returns None.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
loaded = await manager.load_from_cache("nonexistent-game")
assert loaded is None
@pytest.mark.asyncio
async def test_delete_from_cache(self) -> None:
"""Test deleting game state from cache.
Verifies game is removed and subsequent loads return None.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
game = create_test_game_state()
await manager.save_to_cache(game)
assert await manager.cache_exists(game.game_id)
deleted = await manager.delete_from_cache(game.game_id)
assert deleted is True
assert not await manager.cache_exists(game.game_id)
@pytest.mark.asyncio
async def test_delete_from_cache_not_found(self) -> None:
"""Test deleting game that doesn't exist in cache.
Verifies returns False when game not found.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
deleted = await manager.delete_from_cache("nonexistent-game")
assert deleted is False
@pytest.mark.asyncio
async def test_cache_exists(self) -> None:
"""Test checking if game exists in cache.
Verifies exists returns True for cached games, False otherwise.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
game = create_test_game_state()
assert not await manager.cache_exists(game.game_id)
await manager.save_to_cache(game)
assert await manager.cache_exists(game.game_id)
# =============================================================================
# Database Operation Tests
# =============================================================================
class TestDatabaseOperations:
"""Tests for Postgres database operations."""
@pytest.mark.asyncio
async def test_persist_to_db_creates_new(self, db_session: AsyncSession) -> None:
"""Test persisting game state creates new ActiveGame record.
Verifies a new game is properly stored in Postgres.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
# Create user for FK
user = await UserFactory.create(db_session)
game = create_test_game_state()
# Persist to database
active_game = await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
npc_id="test_npc",
session=db_session,
)
assert active_game.id == game.game_id
assert active_game.game_type == GameType.CAMPAIGN
assert active_game.player1_id == user.id
assert active_game.npc_id == "test_npc"
assert active_game.turn_number == game.turn_number
@pytest.mark.asyncio
async def test_persist_to_db_updates_existing(self, db_session: AsyncSession) -> None:
"""Test persisting game state updates existing ActiveGame record.
Verifies an existing game is updated with new state.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
game = create_test_game_state(turn_number=1)
# Create initial record
await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Update game state
game = GameState.model_validate(
{**game.model_dump(), "turn_number": 10, "phase": TurnPhase.ATTACK.value}
)
# Persist again
active_game = await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Verify update
assert active_game.turn_number == 10
@pytest.mark.asyncio
async def test_load_from_db_found(self, db_session: AsyncSession) -> None:
"""Test loading game state from database when it exists.
Verifies game state is correctly deserialized from Postgres.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
game = create_test_game_state(turn_number=7)
# Persist first
await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Load and verify
loaded = await manager.load_from_db(game.game_id, session=db_session)
assert loaded is not None
assert loaded.game_id == game.game_id
assert loaded.turn_number == 7
@pytest.mark.asyncio
async def test_load_from_db_not_found(self, db_session: AsyncSession) -> None:
"""Test loading game state from database when it doesn't exist.
Verifies returns None for missing games.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
loaded = await manager.load_from_db(str(uuid4()), session=db_session)
assert loaded is None
@pytest.mark.asyncio
async def test_delete_from_db(self, db_session: AsyncSession) -> None:
"""Test deleting game from database.
Verifies game is removed from ActiveGame table.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
game = create_test_game_state()
# Create game
await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Delete it
deleted = await manager.delete_from_db(game.game_id, session=db_session)
assert deleted is True
# Verify gone
loaded = await manager.load_from_db(game.game_id, session=db_session)
assert loaded is None
@pytest.mark.asyncio
async def test_delete_from_db_not_found(self, db_session: AsyncSession) -> None:
"""Test deleting game that doesn't exist in database.
Verifies returns False for missing games.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
deleted = await manager.delete_from_db(str(uuid4()), session=db_session)
assert deleted is False
# =============================================================================
# High-Level Operation Tests
# =============================================================================
class TestHighLevelOperations:
"""Tests for high-level game state operations."""
@pytest.mark.asyncio
async def test_load_state_cache_hit(self) -> None:
"""Test load_state returns cached data when available.
Verifies the fast path uses Redis when game is cached.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
game = create_test_game_state()
# Cache the game
await manager.save_to_cache(game)
# Load should use cache (fast path)
loaded = await manager.load_state(game.game_id)
assert loaded is not None
assert loaded.game_id == game.game_id
@pytest.mark.asyncio
async def test_load_state_cache_miss_db_hit(self, db_session: AsyncSession) -> None:
"""Test load_state falls back to database on cache miss.
Verifies database fallback and cache repopulation.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
game = create_test_game_state()
# Only in database, not in cache
await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Load should fallback to DB (pass session for test)
loaded = await manager.load_state(game.game_id, session=db_session)
assert loaded is not None
assert loaded.game_id == game.game_id
# Should now be in cache
assert await manager.cache_exists(game.game_id)
@pytest.mark.asyncio
async def test_load_state_not_found(self, db_session: AsyncSession) -> None:
"""Test load_state returns None when game doesn't exist anywhere.
Verifies proper handling of missing games.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
loaded = await manager.load_state(str(uuid4()), session=db_session)
assert loaded is None
@pytest.mark.asyncio
async def test_delete_game(self, db_session: AsyncSession) -> None:
"""Test delete_game removes from both cache and database.
Verifies complete cleanup of game state.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
game = create_test_game_state()
# Create in both cache and DB
await manager.save_to_cache(game)
await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Delete completely (pass session)
deleted = await manager.delete_game(game.game_id, session=db_session)
assert deleted is True
# Verify gone from both
assert not await manager.cache_exists(game.game_id)
loaded = await manager.load_from_db(game.game_id, session=db_session)
assert loaded is None
@pytest.mark.asyncio
async def test_recover_active_games(self, db_session: AsyncSession) -> None:
"""Test recovering active games from database to cache on startup.
Verifies all games are loaded from Postgres and cached in Redis.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
# Create multiple games in DB only
game1 = create_test_game_state()
game2 = create_test_game_state()
await manager.persist_to_db(
game1,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
await manager.persist_to_db(
game2,
game_type=GameType.FREEPLAY,
player1_id=user.id,
session=db_session,
)
# Clear cache
helper.store.clear()
# Recover (pass session)
result = await manager.recover_active_games(session=db_session)
assert result.recovered == 2
assert result.total == 2
assert len(result.failed) == 0
# Both should be in cache now
assert await manager.cache_exists(game1.game_id)
assert await manager.cache_exists(game2.game_id)
@pytest.mark.asyncio
async def test_get_player_active_games(self, db_session: AsyncSession) -> None:
"""Test getting all active games for a player.
Verifies correct games are returned for a specific player.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user1 = await UserFactory.create(db_session)
user2 = await UserFactory.create(db_session)
# Game where user1 is player1
game1 = create_test_game_state()
await manager.persist_to_db(
game1,
game_type=GameType.CAMPAIGN,
player1_id=user1.id,
session=db_session,
)
# Game where user1 is player2
game2 = create_test_game_state()
await manager.persist_to_db(
game2,
game_type=GameType.FREEPLAY,
player1_id=user2.id,
player2_id=user1.id,
session=db_session,
)
# Game for different user
game3 = create_test_game_state()
await manager.persist_to_db(
game3,
game_type=GameType.CAMPAIGN,
player1_id=user2.id,
session=db_session,
)
# Get user1's games
games = await manager.get_player_active_games(user1.id, session=db_session)
game_ids = {str(g.id) for g in games}
assert game1.game_id in game_ids
assert game2.game_id in game_ids
assert game3.game_id not in game_ids
# =============================================================================
# Integration Tests with Real Redis
# =============================================================================
class TestRealRedisIntegration:
"""Integration tests using real Redis (from testcontainers)."""
@pytest.mark.asyncio
async def test_save_and_load_with_real_redis(self, redis_url: str) -> None:
"""Test full save/load cycle with real Redis client.
Verifies the complete flow works with actual Redis.
Creates Redis client inside test to avoid event loop issues.
"""
import redis.asyncio as aioredis
# Create client inside test (same event loop)
client = aioredis.from_url(redis_url, decode_responses=True)
try:
# Clear test database
await client.flushdb()
# Create a helper that uses the test client
class TestRedisHelper(RedisHelper):
def __init__(self, redis_client: Any) -> None:
self._client = redis_client
async def get_json(self, key: str) -> dict | None:
value = await self._client.get(key)
if value is None:
return None
return json.loads(value)
async def set_json(
self, key: str, value: dict, expire_seconds: int | None = None
) -> None:
json_str = json.dumps(value, default=str)
if expire_seconds:
await self._client.setex(key, expire_seconds, json_str)
else:
await self._client.set(key, json_str)
async def delete(self, key: str) -> bool:
result = await self._client.delete(key)
return result > 0
async def exists(self, key: str) -> bool:
result = await self._client.exists(key)
return result > 0
helper = TestRedisHelper(client)
manager = GameStateManager(redis=helper)
game = create_test_game_state(turn_number=42)
# Save to real Redis
await manager.save_to_cache(game)
# Load from real Redis
loaded = await manager.load_from_cache(game.game_id)
assert loaded is not None
assert loaded.game_id == game.game_id
assert loaded.turn_number == 42
# Clean up
await manager.delete_from_cache(game.game_id)
assert not await manager.cache_exists(game.game_id)
finally:
await client.flushdb()
await client.aclose()
# =============================================================================
# Additional Coverage Tests
# =============================================================================
class TestAdditionalCoverage:
"""Tests to fill coverage gaps identified in review."""
@pytest.mark.asyncio
async def test_get_active_game_count(self, db_session: AsyncSession) -> None:
"""Test counting active games in database.
Verifies the monitoring/admin dashboard helper works correctly.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
# Initially zero
count = await manager.get_active_game_count(session=db_session)
assert count == 0
# Create some games
game1 = create_test_game_state()
game2 = create_test_game_state()
await manager.persist_to_db(
game1,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
await manager.persist_to_db(
game2,
game_type=GameType.FREEPLAY,
player1_id=user.id,
session=db_session,
)
# Now should be 2
count = await manager.get_active_game_count(session=db_session)
assert count == 2
@pytest.mark.asyncio
async def test_recover_active_games_with_invalid_state(self, db_session: AsyncSession) -> None:
"""Test recovery handles corrupted game state gracefully.
Verifies the exception handler in recover_active_games() logs errors
but continues recovering other games.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
# Create a valid game
valid_game = create_test_game_state()
await manager.persist_to_db(
valid_game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Manually insert a game with invalid/corrupted state
from sqlalchemy import text
invalid_game_id = str(uuid4())
await db_session.execute(
text("""
INSERT INTO active_games
(id, game_type, player1_id, rules_config, game_state, turn_number,
started_at, last_action_at, created_at, updated_at)
VALUES
(:id, 'CAMPAIGN', :player_id, '{}', '{"invalid": "not a valid game state"}',
1, NOW(), NOW(), NOW(), NOW())
"""),
{"id": invalid_game_id, "player_id": str(user.id)},
)
await db_session.flush()
# Clear cache
helper.store.clear()
# Recovery should handle the error and still recover the valid game
result = await manager.recover_active_games(session=db_session)
# Only the valid game should be recovered (invalid one fails validation)
assert result.recovered == 1
assert result.total == 2
assert len(result.failed) == 1
# Verify the failed game ID is reported
failed_ids = [gid for gid, _ in result.failed]
assert invalid_game_id in failed_ids
assert await manager.cache_exists(valid_game.game_id)
assert not await manager.cache_exists(invalid_game_id)
@pytest.mark.asyncio
async def test_delete_game_not_found_anywhere(self, db_session: AsyncSession) -> None:
"""Test delete_game when game doesn't exist in cache or DB.
Verifies the warning log path and returns False.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
# Try to delete a game that doesn't exist anywhere
nonexistent_id = str(uuid4())
deleted = await manager.delete_game(nonexistent_id, session=db_session)
# Should return False (nothing was deleted)
assert deleted is False