Files
FabledScribe/tests/conftest.py
T
bvandeusenandClaude Fable 5 bbee0d0db1
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 18s
refactor(tests): one definition each for the copied fixtures and fakes (#2825, milestone 296 area 1)
The shape ledger showed the same test scaffolding defined over and over:
_bind_user x12 (byte-identical), _dispose_engine x10 in three wordings,
_no_supersession x3, _make_mock_session x7 in three subsets, a get-or-create
User helper x2 (+3 inlined), and fifteen hand-rolled MagicMock note factories
each re-explaining the same "an auto-MagicMock attribute is truthy" hazard
(note 2109).

Now: conftest.py carries _bind_user / _dispose_engine / _no_supersession as
opt-in fixtures (pytestmark = usefixtures(...) per module, so unit tests pay
nothing), and tests/helpers.py carries make_mock_session(), ensure_user() and
fake_note(**attrs) — the hazard documented once, real values on every
attribute the product reads. Call sites were rewritten by AST so titles with
dashes and commas survived; the three SimpleNamespace _note stand-ins that
only feed a single function stay local.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:03:48 -04:00

85 lines
3.1 KiB
Python

"""
Shared pytest fixtures.
Integration tests that need a real database should use a separate PostgreSQL
instance (e.g. a Docker service spun up by the CI job) and set DATABASE_URL
in the environment before importing the app.
For unit tests of pure functions no database is needed at all.
The fixtures below are the ONE definition of three things that used to be
copied into a dozen test modules each (#2825). They are deliberately not
autouse: a module opts in with
``pytestmark = pytest.mark.usefixtures("<name>")`` (or a test names the
fixture as a parameter), so a unit test that never touches the engine or the
MCP context pays nothing for them.
"""
import os
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
@pytest.fixture(autouse=True)
def _isolate_env(request, monkeypatch):
"""Prevent unit tests from accidentally reading production env vars.
Integration tests (marked `integration`) are skipped here: they must use the
real DATABASE_URL injected by the CI integration lane, not the fake one.
"""
if request.node.get_closest_marker("integration"):
return
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test")
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("OLLAMA_URL", "http://localhost:11434")
@pytest.fixture
def _bind_user():
"""Bind MCP caller #7 for the duration of a test.
The MCP tool layer reads the caller from a ContextVar the HTTP transport
sets per request; a unit test of a tool has no request, so it binds the
caller itself. Every tool-layer test module opts in with
``pytestmark = pytest.mark.usefixtures("_bind_user")`` and builds its fakes
with user_id=7 so ownership checks see the caller as the owner.
"""
from scribe.mcp._context import _user_id_ctx
token = _user_id_ctx.set(7)
yield
_user_id_ctx.reset(token)
@pytest_asyncio.fixture
async def _dispose_engine():
"""Dispose the app's module-level engine after a test that hit Postgres.
The engine pools asyncpg connections per event loop, but pytest-asyncio
runs each test on a fresh loop — so without this, test 2 gets handed
test 1's connection bound to a now-dead loop ("Future attached to a
different loop"). Disposing in the test's own loop teardown clears the
pool cleanly. The import is deferred so merely collecting a module that
mixes unit and integration tests never builds an engine.
"""
from scribe.models import engine
yield
await engine.dispose()
@pytest.fixture
def _no_supersession():
"""Stub the auto-inject menu's "which lines are superseded?" lookup (#278).
That is a real database call on a path the plugin-context tests exercise
without one. Stubbed to "nothing superseded" — the ordinary state — rather
than hidden behind a try/except in the product, which would make the code
lie about what it does. The label's own behaviour is covered in
tests/test_supersession_ranking.py.
"""
with patch("scribe.services.plugin_context.superseded_ids",
AsyncMock(return_value=set())):
yield