Files
FabledScribe/tests/conftest.py
T
bvandeusenandClaude Opus 5 4f2977b848
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Failing after 1m14s
CI & Build / Build & push image (push) Skipped
fix(tasks): add_task_log wrote to a surface no agent could read back (#4241)
The work log reached the web UI through routes/task_logs.py and nothing
else. get_task returned only the body — a claim written once, before the
work — with the record written during it invisible beside it. So a stale
body arrived with nothing to contradict it, and this session rebuilt work
that had already shipped, with the evidence sitting in the task's own logs.

Read side, scoped through the access layer (rule 78):
  - logs_for_task / count_logs_for_task / log_counts_for_tasks in
    services/task_logs.py. Scoped by who may read the TASK rather than by
    who wrote the entry: list_logs filters TaskLog.user_id == user_id,
    which hands a shared collaborator an empty list reading as "no work
    has been done". The page query folds readable_notes_clause into the
    same statement so the permission does not become an N+1.
  - get_task returns work_log; list_tasks and get_milestone steps carry
    log_count, zero-filled so "none" is a count and not a missing key.

Elision keeps both ends. The newest entry arrives whole to 4000 chars
because it answers "where does this stand"; older ones are shortened from
the MIDDLE, never the head. A head cut selects what a reader sees by
character position, which is uncorrelated with what matters — an entry
closing with "so this shipped in 04775c3" loses the one sentence that
answers the question, and a truncated flag says something went, never
whether it mattered. The gap states how many characters it covers.

conftest gains an autouse stub for the new read arm, same reasoning as
_no_rule_arm: three widely-called tools grew a database read, and the
existing call sites should not each have to learn about it.

Raised while reviewing this: search() has the same shape and worse —
body[:240] with no marker at all, while the chunk that actually matched
sits unused in the row that won. Filed as #4243, not fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-21 08:45:43 -04:00

150 lines
5.9 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_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 since milestone 415; 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 (#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
@pytest.fixture(autouse=True)
def _no_task_log_arm():
"""Stub the task-log read arm that get_task / list_tasks / get_milestone
grew in #4241.
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_rule_arm():
"""Stub the write-path hint's standing-RULES arm (milestone 307).
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