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
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>
105 lines
4.6 KiB
Python
105 lines
4.6 KiB
Python
"""Merge records what it absorbed.
|
|
|
|
Merging keeps the target's scalar fields, unions locations + tags, and trashes
|
|
the sources. Without provenance the fact that a variant ever existed survives
|
|
only in the trash — and only for someone who already knew to go looking. So the
|
|
survivor carries `merged_from`, written to the body and the indexed mirror from
|
|
one value, and ordinary edits must carry it forward rather than erase it.
|
|
"""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from grimoire.services import snippets as s
|
|
from tests.helpers import fake_snippet
|
|
|
|
|
|
def _snippet(id=1, owner=9, body=None, data=None, tags=None):
|
|
return fake_snippet(
|
|
id=id, user_id=owner, title="formatDuration — humanize a millisecond count",
|
|
body=body if body is not None else s.compose_body(code="x = 1", language="ts"),
|
|
tags=tags if tags is not None else ["ts", "snippet"], data=data,
|
|
)
|
|
|
|
|
|
async def _run_merge(target, sources, source_ids):
|
|
"""Drive merge_snippets with the DB stubbed out; return update_note's kwargs."""
|
|
by_id = {target.id: target, **{x.id: x for x in sources}}
|
|
|
|
async def fake_get(_uid, sid):
|
|
return by_id.get(sid)
|
|
|
|
with patch.object(s, "get_snippet", AsyncMock(side_effect=fake_get)), \
|
|
patch("grimoire.services.access.can_write_note", AsyncMock(return_value=True)), \
|
|
patch.object(s.notes_svc, "update_note",
|
|
AsyncMock(return_value=target)) as mock_update, \
|
|
patch("grimoire.services.trash.delete", AsyncMock(return_value=object())):
|
|
await s.merge_snippets(7, target.id, source_ids)
|
|
return mock_update.await_args.kwargs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_records_what_it_absorbed_in_body_and_mirror():
|
|
kwargs = await _run_merge(
|
|
_snippet(id=1), [_snippet(id=2), _snippet(id=3)], [2, 3],
|
|
)
|
|
assert "**Merged from:** #2, #3" in kwargs["body"]
|
|
assert s.merged_from_ids(kwargs["data"]["merged_from"]) == [2, 3]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_accumulates_across_merges():
|
|
"""A target merged twice keeps both histories — the second merge must not
|
|
replace the record of the first."""
|
|
target = _snippet(id=1, data=s.compose_data(name="f", merged_from=[2]))
|
|
kwargs = await _run_merge(target, [_snippet(id=3)], [3])
|
|
assert s.merged_from_ids(kwargs["data"]["merged_from"]) == [2, 3]
|
|
assert "**Merged from:** #2, #3" in kwargs["body"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_does_not_record_a_skipped_cross_owner_source():
|
|
"""A source owned by someone else is skipped, not folded in — so it
|
|
must not appear in the provenance either, or the record would claim to
|
|
contain something it never absorbed."""
|
|
kwargs = await _run_merge(
|
|
_snippet(id=1, owner=9),
|
|
[_snippet(id=2, owner=9), _snippet(id=3, owner=42)],
|
|
[2, 3],
|
|
)
|
|
assert s.merged_from_ids(kwargs["data"]["merged_from"]) == [2]
|
|
assert "#3" not in kwargs["body"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_ordinary_edit_carries_provenance_forward():
|
|
"""Only a merge may add to it, but nothing else may drop it — update
|
|
recomposes body and mirror from scratch, so an omission here would silently
|
|
erase the history on the next unrelated edit."""
|
|
note = _snippet(id=1, data=s.compose_data(name="f", language="ts",
|
|
merged_from=[2, 3]))
|
|
with patch.object(s, "get_snippet", AsyncMock(return_value=note)), \
|
|
patch("grimoire.services.access.can_write_note", AsyncMock(return_value=True)), \
|
|
patch.object(s.notes_svc, "update_note",
|
|
AsyncMock(return_value=note)) as mock_update:
|
|
await s.update_snippet(7, 1, signature="f(ms) -> string")
|
|
|
|
kwargs = mock_update.await_args.kwargs
|
|
assert s.merged_from_ids(kwargs["data"]["merged_from"]) == [2, 3]
|
|
assert "**Merged from:** #2, #3" in kwargs["body"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_pre_0070_row_keeps_provenance_through_the_body():
|
|
"""Rows written before the `data` column are never backfilled, so the body
|
|
line is their only copy — and it has to survive an edit too."""
|
|
body = s.compose_body(code="x = 1", language="ts", merged_from=[5])
|
|
note = _snippet(id=1, body=body, data=None)
|
|
with patch.object(s, "get_snippet", AsyncMock(return_value=note)), \
|
|
patch("grimoire.services.access.can_write_note", AsyncMock(return_value=True)), \
|
|
patch.object(s.notes_svc, "update_note",
|
|
AsyncMock(return_value=note)) as mock_update:
|
|
await s.update_snippet(7, 1, when_to_use="humanize a ms count")
|
|
|
|
assert "**Merged from:** #5" in mock_update.await_args.kwargs["body"]
|