252 lines
9.7 KiB
Python
252 lines
9.7 KiB
Python
"""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_<id> 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()
|