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:
parent
50684a1b11
commit
29ab0b3d84
19
backend/app/services/__init__.py
Normal file
19
backend/app/services/__init__.py
Normal 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",
|
||||
]
|
||||
479
backend/app/services/game_state_manager.py
Normal file
479
backend/app/services/game_state_manager.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
1
backend/tests/services/__init__.py
Normal file
1
backend/tests/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for service layer components."""
|
||||
203
backend/tests/services/conftest.py
Normal file
203
backend/tests/services/conftest.py
Normal 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"
|
||||
699
backend/tests/services/test_game_state_manager.py
Normal file
699
backend/tests/services/test_game_state_manager.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user