mantimon-tcg/backend/tests/services/conftest.py
Cal Corum 29ab0b3d84 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)
2026-01-27 10:59:58 -06:00

204 lines
5.4 KiB
Python

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