Files
FabledGrimoire/tests/conftest.py
T
bvandeusenandClaude Opus 5.5 91c96a8b8a
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 13s
docs(6209): coverage, guidance-ownership, shape, warnings tests and shared fixtures
test_pattern_coverage, test_guidance_ownership, test_integration_shape_classify,
test_instruction_surfaces_agree, test_retrieval_warnings, helpers.py and
conftest.py state the property each test or fixture holds. The issue and
milestone numbers, consolidation dates and one-install measurements that
recorded how each was found are dropped; fixture dates and ids stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:21:52 -04:00

333 lines
14 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 things a dozen test modules
each need. 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_embedding():
"""Stub the fire-and-forget embedding refresh a note write detaches.
For integration tests about ids, transactions and access rather than
recall: `embed_note` spawns a task that loads the embedding model, which
outlives the test's event loop and makes the lane slower for nothing. Opt
in alongside `_dispose_engine`.
"""
from unittest.mock import MagicMock
# Milestones embed too; a plan created in a test would
# otherwise detach the same model-loading task.
with patch("scribe.services.notes.embed_note", MagicMock()), \
patch("scribe.services.milestones.embed_milestone", MagicMock()):
yield
@pytest.fixture
def _no_supersession():
"""Stub the auto-inject menu's "which lines are superseded?" lookup.
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
@pytest.fixture(autouse=True)
def _no_reply_shape_log():
"""Stub the reply-shape delivery row.
Autouse for _no_system_labels' reason: every turn's retrieval, every
moment request and every Scribe tool that reaches a shaped moment writes
one AppLog row, and those paths are tested without a database.
tests/test_reply_shapes.py binds the real function at import time,
before this patch runs.
"""
with patch("scribe.services.reply_shapes.record_delivery", AsyncMock()):
yield
@pytest.fixture(autouse=True)
def _no_system_labels():
"""Stub the menu's "which System is each line about?" lookup.
Autouse because every test that renders an injected menu reaches it, and
it is a real database call on a path those tests run without one. Stubbed
to "no labels", the state of any untagged record. Tests of the label
itself patch it with a value.
"""
with patch("scribe.services.plugin_context.system_names_for",
AsyncMock(return_value={})):
yield
@pytest.fixture(autouse=True)
def _no_rulings_arm():
"""Stub the rulings arm both PreToolUse builders run.
Autouse for _no_system_labels' reason: every write-path and tool-rule
test with a project in scope reaches it, and its first act is reading the
project's Systems from Postgres. Stubbed to "no area's files touched".
tests/test_system_rulings.py binds the real function at import time,
before this patch runs.
"""
with patch("scribe.services.system_rulings.rulings_for_paths",
AsyncMock(return_value={"lines": [], "system_ids": []})):
yield
@pytest.fixture(autouse=True)
def _no_system_usage_readout():
"""Stub the per-System usage lookup the Systems list and get_system carry.
Autouse for _no_rulings_arm's reason: every route and tool test that
reads a System now reaches a GROUP BY on system_usage_events. Stubbed to
the zero shape for whatever ids were asked. tests/test_system_usage_readout.py
binds the real function at import time, before this patch runs.
"""
from scribe.services.system_usage import empty_system_usage
async def _zeros(system_ids):
return {int(s): empty_system_usage() for s in system_ids}
with patch("scribe.services.system_usage.usage_for_systems",
AsyncMock(side_effect=_zeros)):
yield
@pytest.fixture(autouse=True)
def _no_task_log_arm():
"""Stub the task-log read arm that get_task / list_tasks / get_milestone
carry.
Autouse for the reason _no_rule_arm is: those three tools now read work
logs, and the reads go through the access layer to Postgres. Every unit
test that opens a task — and most of them do, because a task is what this
codebase is mostly about — would otherwise try to reach the fake
DATABASE_URL this file sets, to learn that a fake task has no logs.
The arm's own behaviour is covered where it belongs: the payload shape and
the tool wiring in tests/test_task_work_log_surface.py, which re-patches
these explicitly, and the ACL scoping against real Postgres in
tests/test_integration_task_work_log.py. A test that wants the arm live
re-patches it, same as the rules arm.
"""
with patch("scribe.services.task_logs.logs_for_task",
AsyncMock(return_value=[])), \
patch("scribe.services.task_logs.count_logs_for_task",
AsyncMock(return_value=0)), \
patch("scribe.services.task_logs.log_counts_for_tasks",
AsyncMock(return_value={})):
yield
@pytest.fixture(autouse=True)
def _no_lesson_rule_links(request):
"""Stub the lesson↔rule link decorations.
Every door that returns a lesson or a rule now attaches its links, and the
read is a real database call — so every unit test that opens either would
otherwise reach for the fake DATABASE_URL to learn that nothing is linked.
Both decorations are fail-open, so the cost would be a slow failed connect
per test rather than a failure, which is worse: it would never be noticed.
The candidate search a new lesson runs is stubbed to "nothing
resembles it" for the same reason: it embeds, and the model load would
otherwise ride along with every unit test that creates a lesson. The
co-surfacing recorder is stubbed to "no proposal": every hook
builder test that renders a lesson beside a rule would otherwise reach
for the database to count the pair. And the via-lesson rule step
is stubbed to "no lesson brought a rule": all three rule arms run it, and
its first act is a database read. tests/test_rule_via_lesson.py binds the
real function at import time, before this patch runs. The convergence
check a "no rule fits" answer runs is stubbed to "no group" for
the same reason; tests/test_lesson_convergence.py binds the real one.
Skipped for integration tests, which exercise the real links against
Postgres (tests/test_integration_lesson_rule_links.py).
"""
if request.node.get_closest_marker("integration"):
yield
return
with patch("scribe.services.lesson_rules.attach_lesson_rules", AsyncMock()), \
patch("scribe.services.lesson_rules.attach_rule_lessons", AsyncMock()), \
patch("scribe.services.lesson_rules.rule_candidates", AsyncMock(return_value=[])), \
patch("scribe.services.lesson_rules.co_surfaced", AsyncMock(return_value="")), \
patch("scribe.services.plugin_context._rules_via_lessons",
AsyncMock(return_value=([], []))), \
patch("scribe.services.lesson_rules.convergence_for", AsyncMock(return_value=None)):
yield
@pytest.fixture(autouse=True)
def _no_family_triggers(request):
"""Stub family canon's write-time triggers.
create_note, create_task, create_snippet, the updates that change a body,
and update_milestone(status="done") now look for a pattern carried between
projects, and each look is a database read (the repeat check also embeds).
All are fail-open, so unstubbed they would cost a slow failed connect per
unit test rather than a failure. Patched at the trigger functions, beneath
`attach_family_hint`, so the attach itself — its order and its fail-open —
still runs. tests/test_family.py binds the real pure parts; the triggers
themselves are exercised against Postgres in
tests/test_integration_family_promotion.py, which this skips.
"""
if request.node.get_closest_marker("integration"):
yield
return
with patch("scribe.services.family.citation_trigger", AsyncMock(return_value=None)), \
patch("scribe.services.family.repeat_trigger", AsyncMock(return_value=None)), \
patch("scribe.services.family.milestone_trigger", AsyncMock(return_value=None)), \
patch("scribe.services.family.milestone_is_open", AsyncMock(return_value=False)):
yield
@pytest.fixture(autouse=True)
def _no_moment_delivery(request):
"""Stub the moment lookup the mapped MCP tools attach.
update_task, the create_* record tools and the milestone tools now hand
back the rules mounted on the moment they mark, and finding those is two
database reads. Patched BENEATH the attach, at `deliver_for_act`, so the
attach itself — its fail-open and its non-dict pass-through — still runs.
Skipped for integration tests; tests/test_moment_delivery.py binds the
real one.
"""
if request.node.get_closest_marker("integration"):
yield
return
from scribe.services.retrieval_pipeline import RuleResult
with patch("scribe.services.moment_delivery.deliver_for_act",
AsyncMock(return_value=([], RuleResult()))):
yield
@pytest.fixture(autouse=True)
def _no_rule_arm():
"""Stub the write-path hint's standing-RULES arm.
Autouse, and deliberately so. The arm calls semantic_search_rules, which
loads the embedding model — so every unrelated plugin-context test that
already stubs the NOTES search would otherwise pull a real model into a
unit test through the one arm it forgot to stub. The forty-odd existing
call sites should not each have to learn about a new arm.
The arm's own behaviour is covered where it belongs: the document shape in
tests/test_services_rule_embeddings.py, the surfacing rules against real
Postgres in tests/test_integration_rule_surfacing.py, and the hook's dedup
channel in tests/test_write_path_trigger.py. A test that wants the arm
live can re-patch it.
"""
with patch("scribe.services.plugin_context.semantic_search_rules",
AsyncMock(return_value=[])):
yield
@pytest.fixture(autouse=True)
def _no_rule_overlap():
"""Stub the rule/preference create path's overlap check.
The same reason as _no_rule_arm, one door over: every create_rule /
create_project_rule / create_preference now asks semantic_search_rules
whether an existing record answers the same moment, so each existing
rule-tool unit test would load the embedding model through a call it never
meant to make. The check's own behaviour is tested in
tests/test_rule_overlap_gate.py, which binds the real function at import
time — before this patch runs — and stubs the search beneath it instead.
"""
with patch("scribe.services.dedup.find_overlapping_rules",
AsyncMock(return_value=[])):
yield
@pytest.fixture(autouse=True)
def _default_gate_bars():
"""Read the create gate's bars as their defaults, not from settings.
Every create through the note gate now asks the user's settings for its
similarity bars, which is a database read on a path the gate's unit tests
run without one. Stubbed one level down — `_gate_setting`, not `gate_bars`
— so the copy-band logic above it (which kinds read an overlap floor, the
floor never sitting above the bar) still runs in every test. The setting
read itself is tested in tests/test_gate_settings.py, which binds the real
function at import time, before this patch runs.
"""
from scribe.services.dedup import GATE_DEFAULT_THRESHOLDS
async def _default(user_id, key, lo):
return GATE_DEFAULT_THRESHOLDS[key]
with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_default)):
yield