"""Shared test fixtures. The ASGI app is driven in-process via httpx + asgi-lifespan (no network, no running server). DB/Redis-backed integration fixtures arrive with the data layer (plan §11 step 2). DB safety --------- Integration fixtures call ``Base.metadata.drop_all`` / ``create_all`` on ``get_engine()``. That engine is built from ``DATABASE_URL``, which in normal runs points at the *developer's* database — ``localhost:5432/mcma`` for host ``pytest`` and ``db:5432/mcma`` for ``make test-api`` (which execs ``pytest`` inside the api container). Running the suite there silently wipes real data: ``drop_all`` removes every ORM table while leaving Alembic's ``alembic_version`` (it lives outside ``Base.metadata``) — the exact "tables keep disappearing, version survives" symptom. To make that impossible, this module redirects every test to a dedicated ``_test`` database *before settings/engine load*, and creates it on demand. The real dev database is never opened by the test suite. """ import asyncio import os from collections.abc import AsyncIterator from pathlib import Path from urllib.parse import urlsplit, urlunsplit import pytest from asgi_lifespan import LifespanManager from httpx import ASGITransport, AsyncClient # Force a test-safe environment before settings load. os.environ.setdefault("ENVIRONMENT", "test") os.environ.setdefault("JWT_SECRET", "test-secret") def _base_database_url() -> str: """Resolve the DB URL the app *would* use, mirroring pydantic-settings precedence: real env var → ``.env`` file → the app's compiled-in default.""" if env := os.environ.get("DATABASE_URL"): return env dotenv = Path(__file__).resolve().parents[1] / ".env" if dotenv.exists(): for raw in dotenv.read_text().splitlines(): line = raw.strip() if line.startswith("DATABASE_URL=") and not line.startswith("#"): return line.split("=", 1)[1].strip().strip("\"'") return "postgresql+asyncpg://mcma:mcma@localhost:5432/mcma" def _with_database(url: str, name: str) -> str: """Return ``url`` with its database name swapped for ``name``.""" return urlunsplit(urlsplit(url)._replace(path=f"/{name}")) _BASE_DB_URL = _base_database_url() _BASE_DB_NAME = urlsplit(_BASE_DB_URL).path.lstrip("/") # Idempotent: if we're already pointed at a *_test DB, keep it as-is. _TEST_DB_NAME = _BASE_DB_NAME if _BASE_DB_NAME.endswith("_test") else f"{_BASE_DB_NAME}_test" _TEST_DB_URL = _with_database(_BASE_DB_URL, _TEST_DB_NAME) # Redirect the whole suite to the test DB before anything reads settings. os.environ["DATABASE_URL"] = _TEST_DB_URL async def _create_test_db_if_missing() -> None: """Create ``_test`` if the server is reachable. Best-effort: if Postgres is down the integration fixtures skip on their own reachability probe, so a failure here must stay silent rather than break unit-only runs.""" import asyncpg # type: ignore[import-untyped] # driver behind postgresql+asyncpg # asyncpg wants a plain libpq DSN (no SQLAlchemy "+asyncpg" suffix), against # the always-present ``postgres`` maintenance database. dsn = _with_database(_TEST_DB_URL, "postgres").replace("+asyncpg", "") try: async with asyncio.timeout(5): conn = await asyncpg.connect(dsn) except Exception: return try: exists = await conn.fetchval("SELECT 1 FROM pg_database WHERE datname = $1", _TEST_DB_NAME) if not exists: # CREATE DATABASE can't run inside a transaction; asyncpg's implicit # autocommit on a bare connection handles that. await conn.execute(f'CREATE DATABASE "{_TEST_DB_NAME}"') finally: await conn.close() @pytest.fixture(scope="session", autouse=True) def _ensure_test_database() -> None: """Guarantee the dedicated test database exists once per session.""" asyncio.run(_create_test_db_if_missing()) @pytest.fixture async def client() -> AsyncIterator[AsyncClient]: from app.main import create_app app = create_app() async with LifespanManager(app): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac