"""Test fixtures. DB-backed tests require ``TEST_DATABASE_URL`` (an async URL to a *disposable* Postgres); without it they skip, so the no-DB unit suite still runs anywhere. The schema is built from the models via ``create_all`` and dropped per test for isolation. A capturing mailer replaces the real one so email flows are testable. """ import os import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine import app.models # noqa: F401 — register all models on Base.metadata from app.api.deps import get_mailer, get_objectstore from app.core.db import get_session from app.integrations.mailer.base import Mailer from app.integrations.objectstore.base import ObjectStore from app.main import app from app.models import Base TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") class CapturingMailer(Mailer): def __init__(self) -> None: self.verifications: list[tuple[str, str]] = [] self.resets: list[tuple[str, str]] = [] async def send_email_verification(self, *, to: str, link: str) -> None: self.verifications.append((to, link)) async def send_password_reset(self, *, to: str, link: str) -> None: self.resets.append((to, link)) class FakeObjectStore(ObjectStore): def __init__(self) -> None: self.objects: dict[str, tuple[bytes, str]] = {} async def ensure_bucket(self) -> None: pass async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: self.objects[key] = (data, content_type) async def get_object(self, *, key: str) -> bytes: return self.objects[key][0] async def presigned_get_url(self, *, key: str) -> str: return f"https://objects.test/{key}" async def delete_object(self, *, key: str) -> None: self.objects.pop(key, None) _mailer = CapturingMailer() _store = FakeObjectStore() @pytest.fixture def mailer() -> CapturingMailer: return _mailer @pytest_asyncio.fixture async def client(): if not TEST_DATABASE_URL: pytest.skip("TEST_DATABASE_URL not set") engine = create_async_engine(TEST_DATABASE_URL) async with engine.begin() as conn: await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) async def _override_session(): async with sessionmaker() as session: yield session _mailer.verifications.clear() _mailer.resets.clear() _store.objects.clear() app.dependency_overrides[get_session] = _override_session app.dependency_overrides[get_mailer] = lambda: _mailer app.dependency_overrides[get_objectstore] = lambda: _store transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as http_client: yield http_client app.dependency_overrides.clear() await engine.dispose() def token_from_link(link: str) -> str: return link.split("token=", 1)[1] async def register(client, email: str, password: str = "password123") -> str: """Register a user and return their bearer session token.""" resp = await client.post( "/api/v1/auth/register", json={"email": email, "password": password} ) assert resp.status_code == 201, resp.text return resp.json()["token"] def auth(token: str) -> dict: return {"Authorization": f"Bearer {token}"}