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