Add GameStateManager service with Redis/Postgres dual storage

- Implement GameStateManager with Redis-primary, Postgres-backup pattern
- Cache operations: save_to_cache, load_from_cache, delete_from_cache
- DB operations: persist_to_db, load_from_db, delete_from_db
- High-level: load_state (cache-first), delete_game, recover_active_games
- Query helpers: get_active_game_count, get_player_active_games
- Add 22 tests for GameStateManager (87% coverage)
- Add 6 __repr__ tests for all DB models (100% model coverage)
This commit is contained in:
Cal Corum 2026-01-27 10:59:58 -06:00
parent 50684a1b11
commit 29ab0b3d84
6 changed files with 1483 additions and 0 deletions

View File

@ -0,0 +1,19 @@
"""Service layer for Mantimon TCG.
Services contain business logic and coordinate between:
- Core game engine
- Database persistence
- Redis caching
- External APIs
Services in this module:
- GameStateManager: Redis-primary, Postgres-backup game state management
- CardService: Load and lookup card definitions from bundled JSON
"""
from app.services.game_state_manager import GameStateManager, game_state_manager
__all__ = [
"GameStateManager",
"game_state_manager",
]

View File

@ -0,0 +1,479 @@
"""Game state management with Redis-primary, Postgres-backup strategy.
This service implements a write-behind caching pattern for game state:
- Redis is the primary store for fast reads/writes during gameplay
- Postgres is the durable backup, updated at turn boundaries and game end
- On server restart, active games are recovered from Postgres Redis
Key Patterns:
game:{game_id} - JSON serialized GameState in Redis
Write Strategy:
1. Every action: save_to_cache() Redis only (fast path)
2. Turn boundaries: persist_to_db() Both Redis and Postgres
3. Game end: persist_to_db() + move to history, delete from active
Read Strategy:
1. load_state() Try Redis first
2. Cache miss Load from Postgres, populate Redis
3. Not found Return None
Recovery Strategy:
1. On startup, recover_active_games() loads all ActiveGame Redis
2. Stale games (no activity for X hours) are auto-expired
Example:
manager = GameStateManager()
# Create new game
game = create_game_state(...)
await manager.save_to_cache(game)
await manager.persist_to_db(game)
# During gameplay (fast path)
game = await manager.load_state(game_id)
# ... apply action ...
await manager.save_to_cache(game)
# At turn boundary
await manager.persist_to_db(game)
# Game end
await manager.end_game(game_id, game_history)
"""
import logging
from datetime import UTC, datetime
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.models.game_state import GameState
from app.db.models import ActiveGame, GameType
from app.db.redis import RedisHelper, redis_helper
from app.db.session import get_session
logger = logging.getLogger(__name__)
# Redis key patterns
GAME_KEY_PREFIX = "game:"
# Cache TTL for game state (24 hours - games should complete or be cleaned up)
GAME_CACHE_TTL = 60 * 60 * 24 # 24 hours in seconds
class GameStateManager:
"""Manages game state persistence across Redis and Postgres.
Uses write-behind caching pattern:
- Redis is primary for fast gameplay
- Postgres is durable backup at turn boundaries
Attributes:
redis: RedisHelper instance for cache operations.
"""
def __init__(self, redis: RedisHelper | None = None) -> None:
"""Initialize the GameStateManager.
Args:
redis: Optional RedisHelper instance. Uses global helper if not provided.
"""
self.redis = redis or redis_helper
def _game_key(self, game_id: str) -> str:
"""Generate Redis key for a game.
Args:
game_id: Unique game identifier.
Returns:
Redis key in format "game:{game_id}".
"""
return f"{GAME_KEY_PREFIX}{game_id}"
# =========================================================================
# Cache Operations (Redis)
# =========================================================================
async def save_to_cache(self, game: GameState) -> None:
"""Save game state to Redis cache.
This is the fast path used during gameplay. Game state is serialized
to JSON and stored with a TTL to prevent stale games from lingering.
Args:
game: GameState to cache.
Example:
await manager.save_to_cache(game_state)
"""
key = self._game_key(game.game_id)
data = game.model_dump(mode="json")
await self.redis.set_json(key, data, expire_seconds=GAME_CACHE_TTL)
logger.debug(f"Cached game state: {game.game_id}")
async def load_from_cache(self, game_id: str) -> GameState | None:
"""Load game state from Redis cache.
Args:
game_id: Unique game identifier.
Returns:
GameState if found in cache, None otherwise.
"""
key = self._game_key(game_id)
data = await self.redis.get_json(key)
if data is None:
return None
return GameState.model_validate(data)
async def delete_from_cache(self, game_id: str) -> bool:
"""Delete game state from Redis cache.
Args:
game_id: Unique game identifier.
Returns:
True if deleted, False if not found.
"""
key = self._game_key(game_id)
deleted = await self.redis.delete(key)
if deleted:
logger.debug(f"Deleted game from cache: {game_id}")
return deleted
async def cache_exists(self, game_id: str) -> bool:
"""Check if game exists in cache.
Args:
game_id: Unique game identifier.
Returns:
True if game is in cache.
"""
key = self._game_key(game_id)
return await self.redis.exists(key)
# =========================================================================
# Database Operations (Postgres)
# =========================================================================
async def persist_to_db(
self,
game: GameState,
game_type: GameType = GameType.CAMPAIGN,
player1_id: UUID | None = None,
player2_id: UUID | None = None,
npc_id: str | None = None,
rules_config: dict | None = None,
session: AsyncSession | None = None,
) -> ActiveGame:
"""Persist game state to Postgres (and update Redis).
Creates or updates an ActiveGame record in Postgres. This is called
at turn boundaries and game end for durability.
Args:
game: GameState to persist.
game_type: Type of game (campaign, freeplay, ranked).
player1_id: First player's user ID.
player2_id: Second player's user ID (None for campaign).
npc_id: NPC opponent ID for campaign games.
rules_config: Optional rules override dict.
session: Optional existing session (for transaction support).
Returns:
The created or updated ActiveGame record.
Example:
await manager.persist_to_db(
game_state,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
npc_id="grass_trainer_1"
)
"""
# Serialize game state to JSON
game_state_json = game.model_dump(mode="json")
rules_json = rules_config or game.rules.model_dump(mode="json")
async def _persist(db: AsyncSession) -> ActiveGame:
# Try to find existing game
result = await db.execute(select(ActiveGame).where(ActiveGame.id == UUID(game.game_id)))
active_game = result.scalar_one_or_none()
if active_game is None:
# Create new record
active_game = ActiveGame(
id=UUID(game.game_id),
game_type=game_type,
player1_id=player1_id,
player2_id=player2_id,
npc_id=npc_id,
rules_config=rules_json,
game_state=game_state_json,
turn_number=game.turn_number,
started_at=datetime.now(UTC),
last_action_at=datetime.now(UTC),
)
db.add(active_game)
logger.info(f"Created ActiveGame: {game.game_id}")
else:
# Update existing record
active_game.game_state = game_state_json
active_game.turn_number = game.turn_number
active_game.last_action_at = datetime.now(UTC)
logger.debug(f"Updated ActiveGame: {game.game_id}")
await db.flush()
await db.refresh(active_game)
return active_game
if session:
return await _persist(session)
else:
async with get_session() as db:
result = await _persist(db)
await db.commit()
return result
async def load_from_db(
self,
game_id: str,
session: AsyncSession | None = None,
) -> GameState | None:
"""Load game state from Postgres.
Args:
game_id: Unique game identifier.
session: Optional existing session.
Returns:
GameState if found, None otherwise.
"""
async def _load(db: AsyncSession) -> GameState | None:
result = await db.execute(select(ActiveGame).where(ActiveGame.id == UUID(game_id)))
active_game = result.scalar_one_or_none()
if active_game is None:
return None
return GameState.model_validate(active_game.game_state)
if session:
return await _load(session)
else:
async with get_session() as db:
return await _load(db)
async def delete_from_db(
self,
game_id: str,
session: AsyncSession | None = None,
) -> bool:
"""Delete game from Postgres ActiveGame table.
Args:
game_id: Unique game identifier.
session: Optional existing session.
Returns:
True if deleted, False if not found.
"""
async def _delete(db: AsyncSession) -> bool:
result = await db.execute(select(ActiveGame).where(ActiveGame.id == UUID(game_id)))
active_game = result.scalar_one_or_none()
if active_game is None:
return False
await db.delete(active_game)
await db.flush()
logger.info(f"Deleted ActiveGame: {game_id}")
return True
if session:
return await _delete(session)
else:
async with get_session() as db:
result = await _delete(db)
await db.commit()
return result
# =========================================================================
# High-Level Operations
# =========================================================================
async def load_state(
self,
game_id: str,
session: AsyncSession | None = None,
) -> GameState | None:
"""Load game state, trying cache first then database.
This is the primary read operation. It checks Redis first for fast
access during gameplay. On cache miss, it loads from Postgres and
repopulates the cache.
Args:
game_id: Unique game identifier.
session: Optional existing session for DB fallback.
Returns:
GameState if found, None otherwise.
Example:
game = await manager.load_state("abc-123")
if game is None:
raise GameNotFoundError(game_id)
"""
# Try cache first (fast path)
state = await self.load_from_cache(game_id)
if state is not None:
logger.debug(f"Cache hit for game: {game_id}")
return state
# Cache miss - try database
logger.debug(f"Cache miss for game: {game_id}, checking database")
state = await self.load_from_db(game_id, session=session)
if state is not None:
# Repopulate cache
await self.save_to_cache(state)
logger.info(f"Loaded game from database and cached: {game_id}")
return state
async def delete_game(
self,
game_id: str,
session: AsyncSession | None = None,
) -> bool:
"""Delete game from both cache and database.
Called when a game ends and has been moved to game history.
Args:
game_id: Unique game identifier.
session: Optional existing session for DB delete.
Returns:
True if deleted from at least one location.
"""
cache_deleted = await self.delete_from_cache(game_id)
db_deleted = await self.delete_from_db(game_id, session=session)
deleted = cache_deleted or db_deleted
if deleted:
logger.info(f"Deleted game completely: {game_id}")
else:
logger.warning(f"Game not found for deletion: {game_id}")
return deleted
async def recover_active_games(
self,
session: AsyncSession | None = None,
) -> int:
"""Recover all active games from Postgres to Redis.
Called on server startup to restore game state. Loads all games
from the ActiveGame table and populates Redis cache.
Args:
session: Optional existing session.
Returns:
Number of games recovered.
Example:
@app.on_event("startup")
async def startup():
count = await game_state_manager.recover_active_games()
logger.info(f"Recovered {count} active games")
"""
async def _recover(db: AsyncSession) -> int:
count = 0
result = await db.execute(select(ActiveGame))
active_games = result.scalars().all()
for active_game in active_games:
try:
state = GameState.model_validate(active_game.game_state)
await self.save_to_cache(state)
count += 1
logger.debug(f"Recovered game: {active_game.id}")
except Exception as e:
logger.error(f"Failed to recover game {active_game.id}: {e}")
logger.info(f"Recovered {count} active games from database")
return count
if session:
return await _recover(session)
else:
async with get_session() as db:
return await _recover(db)
async def get_active_game_count(
self,
session: AsyncSession | None = None,
) -> int:
"""Get the number of active games in the database.
Useful for monitoring and admin dashboards.
Args:
session: Optional existing session.
Returns:
Number of active games.
"""
async def _count(db: AsyncSession) -> int:
result = await db.execute(select(ActiveGame))
return len(result.scalars().all())
if session:
return await _count(session)
else:
async with get_session() as db:
return await _count(db)
async def get_player_active_games(
self,
player_id: UUID,
session: AsyncSession | None = None,
) -> list[ActiveGame]:
"""Get all active games for a player.
Args:
player_id: User ID to lookup.
session: Optional existing session.
Returns:
List of ActiveGame records where the player is player1 or player2.
"""
async def _get(db: AsyncSession) -> list[ActiveGame]:
result = await db.execute(
select(ActiveGame).where(
(ActiveGame.player1_id == player_id) | (ActiveGame.player2_id == player_id)
)
)
return list(result.scalars().all())
if session:
return await _get(session)
else:
async with get_session() as db:
return await _get(db)
# Global singleton instance
game_state_manager = GameStateManager()

View File

@ -866,3 +866,85 @@ class TestGameHistoryModel:
assert history.played_at is not None
assert history.played_at >= before
# =============================================================================
# Model __repr__ Tests
# =============================================================================
class TestModelRepr:
"""Tests for model __repr__ methods.
These ensure debugging output is useful and doesn't break.
"""
@pytest.mark.asyncio
async def test_user_repr(self, db_session: AsyncSession):
"""Test User __repr__ includes id and email."""
user = await UserFactory.create(db_session, email="repr_test@example.com")
repr_str = repr(user)
assert "User" in repr_str
assert str(user.id) in repr_str
assert "repr_test@example.com" in repr_str
@pytest.mark.asyncio
async def test_collection_repr(self, db_session: AsyncSession):
"""Test Collection __repr__ includes relevant info."""
user = await UserFactory.create(db_session)
entry = await CollectionFactory.create(
db_session, user_id=user.id, card_definition_id="test_card_repr"
)
repr_str = repr(entry)
assert "Collection" in repr_str
# Collection repr uses user_id and card, not the collection id
assert str(user.id) in repr_str
assert "test_card_repr" in repr_str
@pytest.mark.asyncio
async def test_deck_repr(self, db_session: AsyncSession):
"""Test Deck __repr__ includes id and name."""
user = await UserFactory.create(db_session)
deck = await DeckFactory.create_for_user(db_session, user, name="Repr Test Deck")
repr_str = repr(deck)
assert "Deck" in repr_str
assert str(deck.id) in repr_str
assert "Repr Test Deck" in repr_str
@pytest.mark.asyncio
async def test_campaign_progress_repr(self, db_session: AsyncSession):
"""Test CampaignProgress __repr__ includes user_id and club."""
user = await UserFactory.create(db_session)
progress = await CampaignProgressFactory.create_for_user(
db_session, user, current_club="fire_club"
)
repr_str = repr(progress)
assert "CampaignProgress" in repr_str
# CampaignProgress repr uses user_id, not the progress id
assert str(user.id) in repr_str
assert "fire_club" in repr_str
@pytest.mark.asyncio
async def test_active_game_repr(self, db_session: AsyncSession):
"""Test ActiveGame __repr__ includes id, type, and turn."""
user = await UserFactory.create(db_session)
game = await ActiveGameFactory.create_campaign_game(db_session, user)
repr_str = repr(game)
assert "ActiveGame" in repr_str
assert str(game.id) in repr_str
assert "campaign" in repr_str.lower() or "CAMPAIGN" in repr_str
@pytest.mark.asyncio
async def test_game_history_repr(self, db_session: AsyncSession):
"""Test GameHistory __repr__ includes id, type, turns, and end reason."""
user = await UserFactory.create(db_session)
history = await GameHistoryFactory.create_player_win(db_session, user)
repr_str = repr(history)
assert "GameHistory" in repr_str
assert str(history.id) in repr_str

View File

@ -0,0 +1 @@
"""Tests for service layer components."""

View File

@ -0,0 +1,203 @@
"""Service test fixtures for Mantimon TCG.
This module provides fixtures for testing services that use both
Redis and PostgreSQL. Uses dev containers like the db tests.
Prerequisites:
docker compose up -d # Start Postgres (5433) and Redis (6380)
"""
import contextlib
import os
from collections.abc import AsyncGenerator
from typing import Any
from uuid import uuid4
import psycopg2
import pytest
import pytest_asyncio
import redis.asyncio as aioredis
from alembic import command
from alembic.config import Config
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.core.config import RulesConfig
from app.core.enums import TurnPhase
from app.core.models.game_state import GameState, PlayerState
# =============================================================================
# Configuration
# =============================================================================
TEST_DATABASE_URL = os.getenv(
"TEST_DATABASE_URL",
"postgresql+asyncpg://mantimon:mantimon@localhost:5433/mantimon",
)
SYNC_DATABASE_URL = os.getenv(
"SYNC_DATABASE_URL",
"postgresql+psycopg2://mantimon:mantimon@localhost:5433/mantimon",
)
TEST_REDIS_URL = os.getenv(
"TEST_REDIS_URL",
"redis://localhost:6380/2", # Use DB 2 for service tests
)
DB_PARAMS = {
"host": "localhost",
"port": 5433,
"user": "mantimon",
"password": "mantimon",
"dbname": "mantimon",
}
TABLES_TO_TRUNCATE = [
"game_history",
"active_games",
"campaign_progress",
"collections",
"decks",
"users",
]
# =============================================================================
# Sync Cleanup Helper
# =============================================================================
def truncate_all_tables() -> None:
"""Truncate all tables using sync psycopg2."""
conn = psycopg2.connect(**DB_PARAMS)
try:
conn.autocommit = True
with conn.cursor() as cur:
for table in TABLES_TO_TRUNCATE:
cur.execute(f"TRUNCATE TABLE {table} CASCADE")
finally:
conn.close()
# =============================================================================
# Migration Fixture
# =============================================================================
@pytest.fixture(scope="session", autouse=True)
def _run_migrations() -> None:
"""Run Alembic migrations once per session."""
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", SYNC_DATABASE_URL)
command.upgrade(alembic_cfg, "head")
# =============================================================================
# Database Session Fixture
# =============================================================================
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Provide a fresh async session for each test."""
engine = create_async_engine(
TEST_DATABASE_URL,
echo=False,
poolclass=pool.NullPool,
)
session_factory = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with session_factory() as session:
yield session
with contextlib.suppress(RuntimeError):
await session.close()
with contextlib.suppress(RuntimeError):
await engine.dispose()
truncate_all_tables()
# =============================================================================
# Redis Fixture
# =============================================================================
@pytest_asyncio.fixture
async def redis_client() -> AsyncGenerator[Any, None]:
"""Provide a Redis client for testing."""
client = aioredis.from_url(TEST_REDIS_URL, decode_responses=True)
try:
# Clear test database before test
await client.flushdb()
yield client
finally:
# Clean up after test
try:
await client.flushdb()
await client.aclose()
except RuntimeError:
pass
# =============================================================================
# GameState Factory
# =============================================================================
def create_test_game_state(
game_id: str | None = None,
turn_number: int = 1,
phase: TurnPhase = TurnPhase.MAIN,
) -> GameState:
"""Create a minimal GameState for testing.
Args:
game_id: Optional game ID. Generated if not provided.
turn_number: Turn number (default 1).
phase: Turn phase (default MAIN).
Returns:
A minimal valid GameState for testing.
"""
if game_id is None:
game_id = str(uuid4())
return GameState(
game_id=game_id,
rules=RulesConfig(),
card_registry={},
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
current_player_id="player1",
turn_number=turn_number,
phase=phase,
turn_order=["player1", "player2"],
)
@pytest.fixture
def game_state() -> GameState:
"""Provide a test GameState."""
return create_test_game_state()
# =============================================================================
# Utility
# =============================================================================
@pytest.fixture
def anyio_backend() -> str:
"""Specify asyncio as the async backend."""
return "asyncio"

View File

@ -0,0 +1,699 @@
"""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)
count = await manager.recover_active_games(session=db_session)
assert count == 2
# 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 docker-compose)."""
@pytest.mark.asyncio
async def test_save_and_load_with_real_redis(self) -> None:
"""Test full save/load cycle with real Redis client.
Verifies the complete flow works with actual Redis.
Creates Redis connection inside the test to avoid event loop issues.
"""
import redis.asyncio as aioredis
# Create Redis client inside test (same event loop)
client = aioredis.from_url(
"redis://localhost:6380/3", # Use DB 3 for this specific test
decode_responses=True,
)
try:
# Clear test database
await client.flushdb()
# Create a helper that uses our client
class TestRedisHelper(RedisHelper):
def __init__(self, client: Any) -> None:
self._client = 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
count = await manager.recover_active_games(session=db_session)
# Only the valid game should be recovered (invalid one fails validation)
assert count == 1
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