"""Shared test fixtures for Mantimon TCG using testcontainers. This module provides database and Redis fixtures that automatically start containers when tests run - no need for `docker compose up` beforehand. Key Features: - Testcontainers auto-starts Postgres and Redis for test session - Containers are shared across all tests (session scope) - Tables are truncated between tests for isolation - Sync psycopg2 used for fixture teardown (avoids event loop issues) Usage: @pytest.mark.asyncio async def test_something(db_session, redis_client): # db_session is an AsyncSession connected to testcontainer Postgres # redis_client is an async Redis client connected to testcontainer Redis pass Environment: The following environment variables are auto-configured for Docker Desktop: - DOCKER_HOST: Set to Docker Desktop socket if default socket not found - TESTCONTAINERS_RYUK_DISABLED: Disabled to avoid Ryuk startup issues """ import contextlib import os from pathlib import Path # ============================================================================= # Docker Environment Configuration # ============================================================================= # Auto-detect Docker Desktop socket if default socket doesn't exist _default_socket = Path("/var/run/docker.sock") _desktop_socket = Path.home() / ".docker/desktop/docker.sock" if not _default_socket.exists() and _desktop_socket.exists(): os.environ.setdefault("DOCKER_HOST", f"unix://{_desktop_socket}") # Disable Ryuk (cleanup container) to avoid startup issues with Docker Desktop os.environ.setdefault("TESTCONTAINERS_RYUK_DISABLED", "true") # ruff: noqa: E402 # Imports must come after environment setup for testcontainers to work correctly from collections.abc import AsyncGenerator from typing import Any 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 testcontainers.postgres import PostgresContainer from testcontainers.redis import RedisContainer # ============================================================================= # Module-level container state (set by session fixtures) # ============================================================================= _postgres_container: PostgresContainer | None = None _redis_container: RedisContainer | None = None _db_params: dict[str, Any] = {} _async_db_url: str = "" _sync_db_url: str = "" _redis_url: str = "" # ============================================================================= # Tables to Truncate (ordered for FK constraints - children first) # ============================================================================= TABLES_TO_TRUNCATE = [ "game_history", "active_games", "campaign_progress", "collections", "decks", "oauth_linked_accounts", "users", ] # ============================================================================= # Container Fixtures (Session-Scoped) # ============================================================================= @pytest.fixture(scope="session") def postgres_container() -> PostgresContainer: """Start a Postgres container for the test session. The container is started once and shared across all tests. It's automatically stopped when the test session ends. """ global _postgres_container, _db_params, _async_db_url, _sync_db_url container = PostgresContainer( image="postgres:15-alpine", username="mantimon", password="mantimon", dbname="mantimon", ) container.start() # Extract connection info host = container.get_container_host_ip() port = container.get_exposed_port(5432) _db_params = { "host": host, "port": int(port), "user": "mantimon", "password": "mantimon", "dbname": "mantimon", } _async_db_url = f"postgresql+asyncpg://mantimon:mantimon@{host}:{port}/mantimon" _sync_db_url = f"postgresql+psycopg2://mantimon:mantimon@{host}:{port}/mantimon" _postgres_container = container yield container container.stop() @pytest.fixture(scope="session") def redis_container() -> RedisContainer: """Start a Redis container for the test session. The container is started once and shared across all tests. It's automatically stopped when the test session ends. """ global _redis_container, _redis_url container = RedisContainer(image="redis:7-alpine") container.start() host = container.get_container_host_ip() port = container.get_exposed_port(6379) _redis_url = f"redis://{host}:{port}/0" _redis_container = container yield container container.stop() @pytest.fixture(scope="session") def redis_url(redis_container: RedisContainer) -> str: """Get the Redis connection URL for the testcontainer. This fixture provides the URL so tests can create their own Redis clients within their own event loop, avoiding the 'attached to different loop' issue. """ return _redis_url # ============================================================================= # Migration Fixture (Session-Scoped) # ============================================================================= @pytest.fixture(scope="session", autouse=True) def _run_migrations(postgres_container: PostgresContainer) -> None: """Run Alembic migrations once per session after Postgres starts. This fixture depends on postgres_container to ensure the database is running before migrations are applied. """ alembic_cfg = Config("alembic.ini") alembic_cfg.set_main_option("sqlalchemy.url", _sync_db_url) command.upgrade(alembic_cfg, "head") # ============================================================================= # Sync Helper Functions (for fixture teardown) # ============================================================================= def truncate_all_tables() -> None: """Truncate all tables using sync psycopg2. This runs in fixture teardown which may be in a different event loop, so we use sync operations to avoid event loop issues. """ if not _db_params: return # Container not started yet 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() # ============================================================================= # Database Session Fixture (Function-Scoped) # ============================================================================= @pytest_asyncio.fixture async def db_session( postgres_container: PostgresContainer, ) -> AsyncGenerator[AsyncSession, None]: """Provide a fresh async session for each test. The session uses NullPool (no connection reuse) and expires on commit=False. Tables are truncated AFTER each test using sync psycopg2 to avoid event loop issues in fixture teardown. Example: async def test_create_user(db_session): user = User(email="test@example.com", ...) db_session.add(user) await db_session.flush() assert user.id is not None """ engine = create_async_engine( _async_db_url, echo=False, poolclass=pool.NullPool, ) session_factory = async_sessionmaker( bind=engine, class_=AsyncSession, expire_on_commit=False, ) session = session_factory() try: yield session finally: with contextlib.suppress(RuntimeError): await session.close() with contextlib.suppress(RuntimeError): await engine.dispose() # SYNC truncate - works regardless of event loop truncate_all_tables() # ============================================================================= # Redis Client Fixture (Function-Scoped) # ============================================================================= @pytest_asyncio.fixture async def redis_client( redis_container: RedisContainer, ) -> AsyncGenerator[Any, None]: """Provide a Redis client for testing. Flushes the database before and after each test for isolation. """ client = aioredis.from_url(_redis_url, decode_responses=True) try: await client.flushdb() yield client finally: try: await client.flushdb() await client.aclose() except RuntimeError: pass # ============================================================================= # Utility Fixtures # ============================================================================= @pytest.fixture def anyio_backend() -> str: """Specify asyncio as the async backend.""" return "asyncio"