Why: - Any test using the client/api_client fixtures runs the app's real lifespan, which calls the production configure_logging() -- setting cache_logger_on_first_use=True (ADR-0011). That 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 once triggered, structlog.testing.capture_logs() silently stops intercepting events in every test that runs afterward in the same pytest process -- order-dependent flakiness with no useful failure message (assertions just see an empty list). Changes: - Added two autouse fixtures: one no-ops configure_logging for tests that spin up the app via LifespanManager (they test HTTP behavior, not logging output, so they don't need the real thing), one resets structlog defaults after every test as defense in depth. Impact: - Test-only; makes capture_logs()-based assertions reliable regardless of test execution order.
86 lines
3.5 KiB
Python
86 lines
3.5 KiB
Python
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
|
|
|
|
|
|
@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
|