Files
FabledGrimoire/tests/test_integration_lesson_reach.py
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

145 lines
5.6 KiB
Python

"""A lesson is reachable from a project it was not written on.
WHY THIS IS AN INTEGRATION TEST
The carve-out is one `OR` inside the query's project filter, and what has to be
proved is which ROWS come back — a mock session returns whatever it was told to
and would pass with the predicate inverted. Every note here embeds identically
to the query, so the only thing that can separate them is the scoping: a leak
and a correct result are otherwise indistinguishable.
The embedder is stubbed, as in the other pgvector tests, so this depends on
Postgres and the query rather than on a downloaded model. No similarity number
is asserted — only membership.
"""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from grimoire.models import async_session
from grimoire.models.embedding import EMBEDDING_DIM, NoteEmbedding
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.services import lessons as lessons_svc
from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
QUERY_VEC = [1.0] + [0.0] * (EMBEDDING_DIM - 1)
@pytest_asyncio.fixture
async def corpus():
"""A lesson and a plain note on project A, plus a lesson on project B.
Fresh users per run: every row matches the query equally, so a record left
behind by another test would read exactly like a scoping leak.
"""
tag = uuid.uuid4().hex[:8]
async with async_session() as s:
owner = await ensure_user(s, f"lesson_reach_owner_{tag}")
await s.flush()
a = Project(user_id=owner.id, title="Where it was learned")
b = Project(user_id=owner.id, title="Somewhere else entirely")
s.add_all([a, b])
await s.flush()
rows = {
"lesson_on_a": Note(
user_id=owner.id, project_id=a.id,
note_type=lessons_svc.LESSON_NOTE_TYPE,
title="Suspect the guard — a test fails on correct code",
body="**When to apply:** a test fails on correct code",
),
"note_on_a": Note(
user_id=owner.id, project_id=a.id, note_type="note",
title="An ordinary note", body="ordinary body",
),
"lesson_on_b": Note(
user_id=owner.id, project_id=b.id,
note_type=lessons_svc.LESSON_NOTE_TYPE,
title="A lesson that lives on B", body="**When to apply:** on B",
),
}
s.add_all(rows.values())
await s.flush()
for note in rows.values():
s.add(NoteEmbedding(
note_id=note.id, chunk_index=0, user_id=owner.id,
embedding=QUERY_VEC, chunk_text=note.title,
chunker_version=CHUNKER_VERSION,
embedding_model=EMBEDDING_MODEL,
))
ids = {k: n.id for k, n in rows.items()}
ids["owner"], ids["a"], ids["b"] = owner.id, a.id, b.id
await s.commit()
return ids
async def _search(uid, **kw):
with patch(
"grimoire.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)
):
hits = await semantic_search_notes(uid, "when does this apply", limit=20, **kw)
return {note.id for _score, note in hits}
async def test_a_lesson_is_found_from_another_project(corpus):
"""THE acceptance this step exists for. Searching project B reaches the
lesson written on project A — the case the kind was created for, because a
transferable insight is most useful on the project that has not learned it
yet."""
found = await _search(
corpus["owner"], project_id=corpus["b"], include_global_kinds=True,
)
assert corpus["lesson_on_a"] in found
assert corpus["lesson_on_b"] in found
async def test_an_ordinary_note_stays_where_it_was_written(corpus):
"""The other half, and the one that would make this change a bug. Project
scoping is deliberate for every other kind; the carve-out admits ONE kind
rather than weakening the filter."""
found = await _search(
corpus["owner"], project_id=corpus["b"], include_global_kinds=True,
)
assert corpus["note_on_a"] not in found
async def test_the_carve_out_is_off_unless_asked_for(corpus):
"""Default off, because the near-duplicate gate and ordinary recall both
depend on the project filter holding. A globally visible kind arriving
there would let a lesson block an unrelated note's create on a project its
author never touched."""
found = await _search(corpus["owner"], project_id=corpus["b"])
assert found == {corpus["lesson_on_b"]}
async def test_the_home_project_is_unchanged(corpus):
"""Searching the project a lesson was written on returns it either way —
the carve-out adds reach, it does not move anything."""
for flag in (False, True):
found = await _search(
corpus["owner"], project_id=corpus["a"], include_global_kinds=flag,
)
assert corpus["lesson_on_a"] in found
assert corpus["note_on_a"] in found
async def test_a_kind_filter_still_means_what_it_says(corpus):
"""The carve-out widens the PROJECT filter only. A caller narrowing to
snippets asked for snippets, and quietly handing it lessons would make
`note_type` mean something different depending on a flag it did not set."""
found = await _search(
corpus["owner"], project_id=corpus["b"],
include_global_kinds=True, note_type="snippet",
)
assert found == set()