- 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)
204 lines
5.4 KiB
Python
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"
|