Files
FabledScribe/tests/test_services_plugin_context.py
T
bvandeusen e9cd3435ad
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 1m4s
test(acl): give search/inject fixtures real owner ids; fail soft on name lookup
Run 2892: 3 pre-existing tests broke, 392 passed. Same shape as the last
breakage — a DB-touching call landed in a path unit tests exercise, and their
fixtures had auto-MagicMock user_id attributes that compare as "someone else's",
sending the code off to look up a username.

Fixed the fixtures rather than the assertion: _fake_note in the search tests and
_note in the plugin-context tests now take a real user_id defaulting to the
caller those tests bind. That makes "is this shared?" meaningful in both files
instead of accidental, which is what the new provenance behaviour actually needs
from them.

Separately, and not as cover for the above: owner_names_for now fails soft.
A lookup error yields no names and callers render "another user". The part that
matters — that the record is NOT the caller's — comes from comparing owner ids,
not from this query, so losing an attribution is cosmetic where failing the whole
search would not be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 23:10:12 -04:00

230 lines
9.9 KiB
Python

from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _rule(rid, title, topic_id):
r = MagicMock()
r.id, r.title, r.topic_id = rid, title, topic_id
r.statement = "FULL STATEMENT SHOULD NOT BE INJECTED"
return r
def _note(nid, title, user_id=1):
n = MagicMock()
n.id, n.title = nid, title
# Real int, defaulting to the caller used in these tests: the injected menu
# compares it to decide whether the line needs a "shared by …" attribution,
# and an auto-MagicMock would read as another user's note.
n.user_id = user_id
return n
# ─── knowledge auto-inject (Path A) ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_autoinject_config_defaults_and_clamps():
from scribe.services import plugin_context as pc
# No settings stored → defaults.
with patch.object(pc, "get_setting", AsyncMock(side_effect=lambda uid, k, d: d)):
cfg = await pc.get_autoinject_config(1)
assert cfg == {
"enabled": pc.AUTOINJECT_DEFAULT_ENABLED,
"threshold": pc.AUTOINJECT_DEFAULT_THRESHOLD,
"top_k": pc.AUTOINJECT_DEFAULT_TOP_K,
}
# Out-of-range values are clamped; top_k capped at the hard ceiling.
stored = {
pc.AUTOINJECT_ENABLED_KEY: "false",
pc.AUTOINJECT_THRESHOLD_KEY: "5",
pc.AUTOINJECT_TOP_K_KEY: "999",
}
with patch.object(pc, "get_setting",
AsyncMock(side_effect=lambda uid, k, d: stored.get(k, d))):
cfg = await pc.get_autoinject_config(1)
assert cfg["enabled"] is False
assert cfg["threshold"] == 1.0
assert cfg["top_k"] == pc._AUTOINJECT_MAX_TOP_K
@pytest.mark.asyncio
async def test_build_autoinject_hint_disabled_returns_empty_and_skips_search():
from scribe.services import plugin_context as pc
search = AsyncMock()
with patch.object(pc, "get_autoinject_config",
AsyncMock(return_value={"enabled": False, "threshold": 0.55, "top_k": 3})), \
patch.object(pc, "semantic_search_notes", search), \
patch.object(pc, "record_retrieval", MagicMock()):
out = await pc.build_autoinject_hint(1, "anything")
assert out["context"] == "" and out["note_ids"] == []
search.assert_not_called() # disabled → no retrieval at all
@pytest.mark.asyncio
async def test_build_autoinject_hint_titles_only_with_margin_gate():
from scribe.services import plugin_context as pc
# top=0.80; 0.74 within band (0.10), 0.61 outside → dropped.
hits = [(0.80, _note(11, "Pool sizing decision")),
(0.74, _note(22, "run_maintenance thresholds")),
(0.61, _note(33, "unrelated-ish"))]
rec = MagicMock()
with patch.object(pc, "get_autoinject_config",
AsyncMock(return_value={"enabled": True, "threshold": 0.55, "top_k": 3})), \
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=hits)), \
patch.object(pc, "record_retrieval", rec):
out = await pc.build_autoinject_hint(1, "postgres pool", project_id=2,
exclude_ids=[99])
# Margin gate kept the top two, dropped the straggler.
assert out["note_ids"] == [11, 22]
assert '#11 "Pool sizing decision" (0.80)' in out["context"]
assert "#33" not in out["context"]
# Title-first: no body text, ever.
assert "get_note(id)" in out["context"]
# Telemetry fired with the auto_inject source and the full candidate set.
rec.assert_called_once()
assert rec.call_args.kwargs["source"] == "auto_inject"
@pytest.mark.asyncio
async def test_build_autoinject_hint_blank_query_returns_empty():
from scribe.services import plugin_context as pc
search = AsyncMock()
with patch.object(pc, "get_autoinject_config",
AsyncMock(return_value={"enabled": True, "threshold": 0.55, "top_k": 3})), \
patch.object(pc, "semantic_search_notes", search):
out = await pc.build_autoinject_hint(1, " ")
assert out["context"] == ""
search.assert_not_called()
@pytest.mark.asyncio
async def test_build_session_context_renders_titles_grouped_by_topic():
rules = [
_rule(1, "`dev` is home", 1),
_rule(2, "Release — never without explicit request", 1),
_rule(3, "No GitHub — Fabled-Git only", 2),
]
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=rules)), \
patch("scribe.services.plugin_context._topic_titles",
AsyncMock(return_value={1: "git-workflow", 2: "fabled-git"})):
from scribe.services.plugin_context import build_session_context
out = await build_session_context(user_id=7, project_id=0)
ctx = out["context"]
assert out["rule_count"] == 3
assert out["project"] is None
# Titles present, grouped under topic headings
assert "### git-workflow" in ctx
assert "### fabled-git" in ctx
assert "- [1] `dev` is home" in ctx
assert "- [3] No GitHub — Fabled-Git only" in ctx
# Full statements must NOT be dumped (push channel injects titles only)
assert "FULL STATEMENT SHOULD NOT BE INJECTED" not in ctx
@pytest.mark.asyncio
async def test_build_session_context_includes_project_when_scoped():
project = MagicMock(id=2, title="FabledScribe", goal="ship it")
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
patch("scribe.services.plugin_context._topic_titles",
AsyncMock(return_value={1: "git-workflow"})), \
patch("scribe.services.plugin_context.projects_svc.get_project",
AsyncMock(return_value=project)), \
patch("scribe.services.plugin_context.notes_svc.list_notes",
AsyncMock(return_value=([], 4))):
from scribe.services.plugin_context import build_session_context
out = await build_session_context(user_id=7, project_id=2)
assert out["project"] == {"id": 2, "title": "FabledScribe"}
assert "## Active project: FabledScribe (id 2)" in out["context"]
assert "Open todo tasks: 4" in out["context"]
@pytest.mark.asyncio
async def test_build_session_context_unbound_repo_emits_bind_hint():
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
patch("scribe.services.plugin_context._topic_titles",
AsyncMock(return_value={1: "git-workflow"})):
from scribe.services.plugin_context import build_session_context
out = await build_session_context(
user_id=7, project_id=0, unbound_repo="host/owner/repo",
)
ctx = out["context"]
assert out["project"] is None
assert "## Repository not yet bound" in ctx
assert 'bind_repo(repo_url="host/owner/repo"' in ctx
# No project block when unbound.
assert "## Active project" not in ctx
@pytest.mark.asyncio
async def test_build_process_manifest_renders_stub_specs():
items = [
{"id": 5, "title": "Drift Audit", "tags": [], "snippet": "Find drifted docs."},
{"id": 9, "title": "DRY Pass", "tags": [], "snippet": ""},
]
with patch("scribe.services.plugin_context.knowledge_svc.query_knowledge",
AsyncMock(return_value=(items, 2))):
from scribe.services.plugin_context import build_process_manifest
out = await build_process_manifest(user_id=7)
assert out["total"] == 2
drift = out["processes"][0]
assert drift["id"] == 5
assert drift["name"] == "Drift Audit"
assert drift["slug"] == "drift-audit" # kebab-cased
assert "Drift Audit" in drift["description"] # auto-surface trigger
assert "Find drifted docs." in drift["description"] # preview folded in
# No-snippet process still gets a usable description.
assert "DRY Pass" in out["processes"][1]["description"]
@pytest.mark.asyncio
async def test_build_process_manifest_dedupes_slugs_and_skips_blank_titles():
items = [
{"id": 1, "title": "My Process", "tags": [], "snippet": "a"},
{"id": 2, "title": "my process", "tags": [], "snippet": "b"}, # same slug
{"id": 3, "title": " ", "tags": [], "snippet": "skip me"}, # blank title
]
with patch("scribe.services.plugin_context.knowledge_svc.query_knowledge",
AsyncMock(return_value=(items, 3))):
from scribe.services.plugin_context import build_process_manifest
out = await build_process_manifest(user_id=7)
slugs = [p["slug"] for p in out["processes"]]
assert slugs == ["my-process", "my-process-2"] # collision suffixed with id
assert out["total"] == 2 # blank-title entry dropped
@pytest.mark.asyncio
async def test_build_process_manifest_truncates_long_preview():
items = [{"id": 1, "title": "Big", "tags": [], "snippet": "x" * 500}]
with patch("scribe.services.plugin_context.knowledge_svc.query_knowledge",
AsyncMock(return_value=(items, 1))):
from scribe.services.plugin_context import build_process_manifest
out = await build_process_manifest(user_id=7)
assert "…" in out["processes"][0]["description"]
assert "x" * 500 not in out["processes"][0]["description"]
@pytest.mark.asyncio
async def test_build_session_context_caps_length():
many = [_rule(i, "x" * 200, 1) for i in range(200)]
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=many)), \
patch("scribe.services.plugin_context._topic_titles",
AsyncMock(return_value={1: "git-workflow"})):
from scribe.services.plugin_context import build_session_context
out = await build_session_context(user_id=7)
assert len(out["context"]) <= 9000 + 60 # cap + truncation note
assert "truncated" in out["context"]