from collections.abc import AsyncIterator, Iterator import pytest import pytest_asyncio import structlog from asgi_lifespan import LifespanManager from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from src.config import Settings from src.main import create_app # Disposable Postgres/MinIO/Qdrant containers (ADR-0016). Registered here # because `pytest_plugins` is only honoured in the root conftest, and both # `tests/integration/*/` and `tests/e2e/` need the same containers. The # fixtures are session-scoped but lazy, so unit runs still need no Docker. pytest_plugins = ["tests.support.containers"] @pytest.fixture(autouse=True) def _reset_structlog_after_test() -> Iterator[None]: """Undo any real `configure_logging()` call before the next test runs. Any test that exercises the app's lifespan (directly, or via the `client`/ `api_client` fixtures below and in `test_domains_api.py`) calls the real `configure_logging()`, which mutates *global* structlog/stdlib state -- including `cache_logger_on_first_use=True`. Left in place, that setting silently breaks `structlog.testing.capture_logs()` in unrelated tests later in the same pytest process: a module-level `logger = structlog.get_logger(__name__)` cached under the real config no longer routes through `capture_logs()`'s temporary processor swap, so assertions on captured events see nothing (ADR-0016: isolate per test -- this generalizes to global config mutations, not just data). """ yield structlog.reset_defaults() @pytest.fixture def settings() -> Settings: # Every external dependency points at a closed port so unit tests never # reach real infrastructure. For the embedders this matters twice over: # the real defaults are a colleague's Ollama box and OpenAI's paid API, # and ADR-0016 forbids routine runs calling either. Connection-refused is # immediate, so the lifespan's fail-soft warm-up costs nothing here -- # and these tests passing at all is what proves it is fail-soft. return Settings( postgres={"host": "127.0.0.1", "port": 1}, minio={"endpoint": "127.0.0.1:1"}, ingestion={"timeout_seconds": 1.0}, qdrant={"url": "http://127.0.0.1:1"}, app={"readiness_check_timeout_seconds": 0.5}, embedding={ "nomic": {"base_url": "http://127.0.0.1:1/v1", "timeout_seconds": 0.5}, "openai": {"base_url": "http://127.0.0.1:1/v1", "timeout_seconds": 0.5}, }, ) @pytest.fixture def app(settings: Settings) -> FastAPI: return create_app(settings) @pytest.fixture(autouse=True) def _no_real_logging_configuration(monkeypatch: pytest.MonkeyPatch) -> None: """Prevent the app lifespan from calling the real `configure_logging()`. It sets `cache_logger_on_first_use=True` (ADR-0011), which permanently monkeypatches the `.bind` method on whichever module-level `logger = structlog.get_logger(__name__)` instance is used first -- `structlog.reset_defaults()` only resets *global* config, not that per-instance mutation, so real configuration leaking into one test would silently break `structlog.testing.capture_logs()` in every test that runs afterward in the same process (ADR-0016: isolate per test). Tests that spin up the full app via `LifespanManager` (`client`, `api_client`) are testing HTTP behavior, not logging output, so they don't need it for real. """ monkeypatch.setattr("src.bootstrap.lifespan.configure_logging", lambda *a, **k: None) @pytest_asyncio.fixture async def client(app: FastAPI) -> AsyncIterator[AsyncClient]: async with ( LifespanManager(app) as manager, AsyncClient( transport=ASGITransport(app=manager.app), base_url="http://test" ) as async_client, ): yield async_client