import asyncio from collections.abc import AsyncIterator, Iterator from pathlib import Path import httpx import pytest from alembic.config import Config from httpx import ASGITransport, AsyncClient from sqlalchemy import text from sqlalchemy.engine import make_url from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine, ) from sqlalchemy.pool import NullPool from alembic import command from app.auth.passwords import hash_password from app.config import get_settings from app.db import get_db from app.llm import client as llm_client from app.main import app from app.metrics import metrics from app.models import Department, User, UserRole from tests.fake_openai import FakeOpenAI # Tests run against a dedicated database, never the dev one. TEST_DB_NAME = "pablan_test" # Every table a test may write to — a new table must be added here, or # state leaks into the next test. _TABLES = ( "messages, conversations, doc_permissions, document_events, chunks, " "documents, auth_sessions, users, templates, jobs, departments, " "llm_settings, prompt_settings" ) @pytest.fixture(scope="session") def test_db_url() -> str: """Drop, recreate and migrate the test database once per test session.""" admin_url = make_url(get_settings().database_url) assert admin_url.database != TEST_DB_NAME test_url = admin_url.set(database=TEST_DB_NAME) async def prepare() -> None: engine = create_async_engine( admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool ) async with engine.connect() as conn: await conn.execute( text(f"DROP DATABASE IF EXISTS {TEST_DB_NAME} WITH (FORCE)") ) await conn.execute(text(f"CREATE DATABASE {TEST_DB_NAME}")) await engine.dispose() # Sync fixture: no event loop is running here, so asyncio.run is safe — # as is alembic's command API (env.py calls asyncio.run itself). asyncio.run(prepare()) # str(URL) masks the password as "***" — render it explicitly. url_string = test_url.render_as_string(hide_password=False) config = Config(str(Path(__file__).resolve().parents[1] / "alembic.ini")) config.set_main_option("sqlalchemy.url", url_string.replace("%", "%%")) command.upgrade(config, "head") return url_string @pytest.fixture async def db_engine(test_db_url: str) -> AsyncIterator[AsyncEngine]: engine = create_async_engine(test_db_url, poolclass=NullPool) yield engine await engine.dispose() @pytest.fixture(autouse=True) async def _clean_tables(db_engine: AsyncEngine) -> None: async with db_engine.begin() as conn: await conn.execute(text(f"TRUNCATE TABLE {_TABLES} CASCADE")) # The prompt-override cache is module-level and outlives a truncated # prompt_settings, so drop it too or an override leaks into the next test. from app.prompts import overrides as prompt_overrides prompt_overrides.clear() @pytest.fixture async def db(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: factory = async_sessionmaker(db_engine, expire_on_commit=False) async with factory() as session: yield session @pytest.fixture async def client(db_engine: AsyncEngine) -> AsyncIterator[AsyncClient]: factory = async_sessionmaker(db_engine, expire_on_commit=False) async def override_get_db() -> AsyncIterator[AsyncSession]: async with factory() as session: yield session app.dependency_overrides[get_db] = override_get_db transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c app.dependency_overrides.clear() @pytest.fixture async def seeded_user(db: AsyncSession) -> User: department = Department(name="Engineering") db.add(department) await db.flush() user = User( email="pablo@test.dev", name="Pablo Test", role=UserRole.member, password_hash=hash_password("secret123"), department_id=department.id, ) db.add(user) await db.commit() return user @pytest.fixture async def seeded_admin(db: AsyncSession) -> User: department = Department(name="Administration") db.add(department) await db.flush() user = User( email="florian@test.dev", name="Florian Test", role=UserRole.admin, password_hash=hash_password("secret123"), department_id=department.id, ) db.add(user) await db.commit() return user @pytest.fixture(autouse=True) def _reset_metrics() -> Iterator[None]: metrics.reset() yield @pytest.fixture def fake_embed(monkeypatch: pytest.MonkeyPatch) -> None: """Deterministic embeddings instead of llm.client.embed — indexing and retrieval tests never need a live embedding endpoint.""" from app.rag import indexing, retrieval, similarity from tests.embedding_stub import fake_embed as stub # Every module that embeds: patching one and forgetting another shows up # as a live endpoint call in a test that was supposed to be offline. monkeypatch.setattr(indexing, "embed", stub) monkeypatch.setattr(retrieval, "embed", stub) monkeypatch.setattr(similarity, "embed", stub) @pytest.fixture def fake_llm(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeOpenAI]: """Route the LLM client at a fake OpenAI-compatible ASGI app.""" fake = FakeOpenAI() monkeypatch.setattr( llm_client, "_http_client_factory", lambda: httpx.AsyncClient(transport=ASGITransport(app=fake.app)), ) llm_client._client_for.cache_clear() yield fake llm_client._client_for.cache_clear()