"""Shared pytest fixtures + hermetic-DB/Redis bootstrap. The integration tests hit a real Postgres (Postgres-specific types: JSONB, native ENUMs) and, for presence tests, a real Redis (TTL semantics aren't worth faking). To make ``cd backend && pytest`` work from a clean checkout with no manual setup, ``pytest_configure`` below brings up ephemeral Postgres/Redis containers via docker compose and runs the migrations IF ``DATABASE_URL``/``REDIS_URL`` aren't already pointing at something reachable. When they are (dev loop, or the checkpoint runner which stands these up itself), that path is skipped and the existing services are reused. If Docker isn't available and nothing is reachable, the whole DB/Redis-backed suite is skipped with a clear message rather than erroring out dozens of times. """ import asyncio import hashlib import os import socket import subprocess import sys import time from pathlib import Path from urllib.parse import urlsplit import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.config import settings from app.database import get_db from app.main import app TEST_TABLES = ( "avatar_loadouts", "assets", # Profile (app/models/profile.py) -- both FK users.id, profile_favorites # additionally FKs worlds.id; list explicitly for clarity/consistency. "profile_favorites", "profiles", # world_chunk_revisions has no FK to worlds/world_chunks (world.md §4.7 / # world_persistence_impl.md §4.3, deliberate -- deleting a chunk or world # must not lose the audit trail), so it won't be cascade-truncated via # `worlds` the way world_rooms/world_chunks would be. List all three # explicitly rather than rely on cascade for some of them. "world_chunk_revisions", "world_chunks", "world_rooms", "worlds", # Social graph (app/models/social.py) -- all three FK users with # ON DELETE CASCADE, but list explicitly for clarity/consistency. "friendships", "follows", "blocks", # Chat (app/models/chat.py). message_reports.message_id is ON DELETE SET # NULL (not CASCADE, deliberately -- see chat.py), so it wouldn't be # reached by cascading a truncate of messages/conversations; list it # explicitly along with the rest for clarity/consistency. "message_reports", "messages", "conversation_participants", "conversations", # Groups (app/models/groups.py). groups.owner_id is ON DELETE SET NULL # (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached # by cascading a truncate of users; list it explicitly for clarity too. "group_members", "groups", # Economy (app/models/wallet.py) -- transactions FK wallets FK users "transactions", "wallets", "sessions", "refresh_tokens", "users", ) BACKEND_DIR = Path(__file__).resolve().parents[1] # A hardcoded project name/port here would mean every concurrent checkout # running `pytest` (e.g. several Claude Agent-tool subagents, each in their # own ~/agp/workspace/task_ clone) fights over the exact same Docker # Compose project and host ports -- one run's pytest_unconfigure teardown # can yank the ephemeral Postgres out from under a completely unrelated # concurrent run, surfacing as "connection was closed in the middle of # operation" with no obvious cause. Confirmed live 2026-09-15 running 3 # parallel subagents. Same root cause and fix pattern as the agent_core # workspace/Docker-Compose collision documented in project_agp memory -- # derive both the project name and the port from a hash of the checkout # directory, so different concurrent checkouts land on different projects/ # ports while the same checkout is stable across repeated runs. _WORKSPACE_HASH = hashlib.sha256(str(BACKEND_DIR.parent).encode()).hexdigest()[:10] _COMPOSE_PROJECT = f"agp-pytest-{_WORKSPACE_HASH}" _EPHEMERAL_PG_PORT = 25400 + (int(_WORKSPACE_HASH, 16) % 100) _EPHEMERAL_REDIS_PORT = 26300 + (int(_WORKSPACE_HASH, 16) % 100) # Set by pytest_configure() when it starts container(s) it owns; torn down in # pytest_unconfigure. _started_container = False _db_unavailable_reason: str | None = None def _port_open(host: str, port: int, timeout: float = 1.0) -> bool: try: with socket.create_connection((host, port), timeout=timeout): return True except OSError: return False def _db_reachable(url: str) -> bool: parts = urlsplit(url) return _port_open(parts.hostname or "localhost", parts.port or 5432) def _redis_reachable(url: str) -> bool: parts = urlsplit(url) return _port_open(parts.hostname or "localhost", parts.port or 6379) def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess: return subprocess.run(cmd, cwd=BACKEND_DIR, capture_output=True, text=True, **kw) def pytest_configure(config): global _started_container, _db_unavailable_reason need_db = not _db_reachable(settings.DATABASE_URL) need_redis = not _redis_reachable(settings.REDIS_URL) if not need_db and not need_redis: return # dev loop / checkpoint runner already provided both if not _run(["docker", "--version"]).returncode == 0: _db_unavailable_reason = ( "no reachable DATABASE_URL/REDIS_URL and docker is unavailable -- run " "the checkpoint suite (scripts/run_checkpoint_tests.py) or start " "Postgres/Redis manually" ) return env = os.environ.copy() env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT) env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT) env["REDIS_HOST_PORT"] = str(_EPHEMERAL_REDIS_PORT) services = [s for s, need in (("postgres", need_db), ("redis", need_redis)) if need] up = _run(["docker", "compose", "up", "-d", *services], env=env) if up.returncode != 0: _db_unavailable_reason = f"docker compose up {' '.join(services)} failed:\n{up.stderr[-800:]}" return _started_container = True if need_db: for _ in range(30): r = _run( ["docker", "compose", "exec", "-T", "postgres", "pg_isready", "-U", "agp_user", "-d", "agp_db"], env=env, ) if r.returncode == 0: break time.sleep(1) else: _db_unavailable_reason = "ephemeral Postgres never became ready" return url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db" os.environ["DATABASE_URL"] = url settings.DATABASE_URL = url mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"], env={**os.environ, "DATABASE_URL": url}) if mig.returncode != 0: _db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}" return if need_redis: for _ in range(30): r = _run( ["docker", "compose", "exec", "-T", "redis", "redis-cli", "ping"], env=env, ) if r.returncode == 0 and "PONG" in r.stdout: break time.sleep(1) else: _db_unavailable_reason = "ephemeral Redis never became ready" return redis_url = f"redis://localhost:{_EPHEMERAL_REDIS_PORT}/0" os.environ["REDIS_URL"] = redis_url settings.REDIS_URL = redis_url def pytest_unconfigure(config): if not _started_container: return env = os.environ.copy() env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT) env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT) env["REDIS_HOST_PORT"] = str(_EPHEMERAL_REDIS_PORT) _run(["docker", "compose", "down", "-v"], env=env) @pytest.fixture(scope="session", autouse=True) def _reset_database(): """Truncate all application tables once per test session.""" if _db_unavailable_reason: pytest.skip(_db_unavailable_reason, allow_module_level=True) async def _truncate() -> None: engine = create_async_engine(settings.DATABASE_URL) async with engine.begin() as conn: await conn.execute(text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")) await engine.dispose() asyncio.run(_truncate()) yield @pytest.fixture(autouse=True) def _reset_rate_limiter(): """Every TestClient request looks like the same client IP, so the per-IP auth limiter would trip mid-suite. Clear it between tests; the dedicated rate-limit test disables this to exercise the real behaviour. """ from app import rate_limit rate_limit.reset_all() yield @pytest.fixture(autouse=True) def _override_db(): """Override get_db so the app uses a fresh session per test. Deliberately does NOT reuse app.database's shared engine: FastAPI's (sync) TestClient runs each request through anyio's blocking-portal machinery, which can hand different requests different event loops. asyncpg connections are bound to the loop they were created on, so reusing one from the shared pool across requests raises "attached to a different loop" the moment a test makes more than one request. NullPool sidesteps it: every checkout is a brand-new connection. """ test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False) async def _get_db(): async_session = test_session_factory() try: yield async_session await async_session.commit() except Exception: await async_session.rollback() raise finally: await async_session.close() app.dependency_overrides[get_db] = _get_db yield app.dependency_overrides.clear()