CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 36s
CI & Build / Build & push image (push) Skipped
Second pass over the tests/ ledger after bbee0d0. fake_record(**attrs) is the
one MagicMock-with-real-attributes builder (to_dict mirrors them; the
note-2109 hazard documented once); fake_note/fake_task/fake_snippet/
fake_project/fake_milestone/fake_system/fake_rulebook/fake_topic/fake_rule
carry each model's ordinary defaults on top of it, replacing 14 per-file
factories (two rulebook trios in tool-vs-service wordings, _fake_task, _fake_ms,
_fake_project, _plan_note, _fake_snippet, two _snippet adapters now one-liners
over fake_snippet). FakeMCP replaces the five closure-over-a-list registrar
fakes (+ _Recorder); loc() and design_token_stub() replace the paired _loc /
_token / _T stand-ins; every hand-built async_session mock (9 helper defs and
14 inline copies) now starts from make_mock_session(). Call sites rewritten by
AST with each file's former defaults made explicit, so behaviour is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
178 lines
7.7 KiB
Python
178 lines
7.7 KiB
Python
"""Tests for un-merge (#2165) — reversing one source out of a merged survivor.
|
|
|
|
The design question this task said to settle first was how to subtract without
|
|
stripping call sites the survivor legitimately owns. The answer is per-source
|
|
attribution recorded AT MERGE TIME: each `merged_from` entry holds only what
|
|
that source actually added. These tests pin that rule, and the refusal that
|
|
guards the case where the attribution isn't there.
|
|
"""
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from scribe.services import snippets as s
|
|
from tests.helpers import loc
|
|
|
|
|
|
def _survivor(locations, merged_from, tags=None, owner=7):
|
|
"""A survivor note whose `data` carries locations + merge provenance."""
|
|
return SimpleNamespace(
|
|
id=1, user_id=owner, note_type="snippet", deleted_at=None,
|
|
title="f — does a thing", tags=tags or ["python", "snippet"],
|
|
body=s.compose_body(code="x = 1", language="python", locations=locations,
|
|
merged_from=merged_from),
|
|
data=s.compose_data(name="f", when_to_use="does a thing", language="python",
|
|
code="x = 1", locations=locations,
|
|
merged_from=merged_from),
|
|
)
|
|
|
|
|
|
async def _run_unmerge(survivor, *, source_alive=None, restore=1):
|
|
"""Drive unmerge_snippet with the DB stubbed; return update_note's kwargs."""
|
|
async def fake_get(_uid, sid):
|
|
if sid == survivor.id:
|
|
return survivor
|
|
return source_alive
|
|
|
|
with (
|
|
patch.object(s, "get_snippet", AsyncMock(side_effect=fake_get)),
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)),
|
|
patch("scribe.services.trash.restore_entity", AsyncMock(return_value=restore)),
|
|
patch.object(s.notes_svc, "update_note",
|
|
AsyncMock(return_value=survivor)) as upd,
|
|
):
|
|
await s.unmerge_snippet(7, 1, 2)
|
|
return upd.await_args.kwargs
|
|
|
|
|
|
# --- the subtraction ------------------------------------------------------
|
|
|
|
|
|
async def test_unmerge_strips_only_what_the_source_contributed():
|
|
"""The survivor's own location survives; the source's is removed."""
|
|
mine, theirs = loc(path="mine.py", repo="r"), loc(path="theirs.py", repo="r")
|
|
survivor = _survivor(
|
|
[mine, theirs],
|
|
[{"id": 2, "locations": [theirs], "tags": ["helper"]}],
|
|
tags=["python", "snippet", "helper", "core"],
|
|
)
|
|
kwargs = await _run_unmerge(survivor)
|
|
assert kwargs["data"]["locations"] == [mine]
|
|
assert "helper" not in kwargs["tags"]
|
|
assert "core" in kwargs["tags"]
|
|
|
|
|
|
async def test_a_location_the_survivor_also_owned_is_never_stripped():
|
|
"""The central hazard the task named. If a source brought a location the
|
|
survivor ALREADY had, merge attributes nothing to it — so reversing must
|
|
leave that call site in place."""
|
|
shared = loc(path="shared.py", repo="r")
|
|
survivor = _survivor([shared], [{"id": 2, "locations": [], "tags": ["t"]}])
|
|
kwargs = await _run_unmerge(survivor)
|
|
assert kwargs["data"]["locations"] == [shared]
|
|
|
|
|
|
async def test_the_reversed_entry_leaves_the_provenance_list():
|
|
survivor = _survivor(
|
|
[loc(path="a.py", repo="r"), loc(path="b.py", repo="r")],
|
|
[{"id": 2, "locations": [loc(path="b.py", repo="r")]}, {"id": 3, "locations": []}],
|
|
)
|
|
kwargs = await _run_unmerge(survivor)
|
|
assert s.merged_from_ids(kwargs["data"]["merged_from"]) == [3]
|
|
assert "#2" not in kwargs["body"]
|
|
# The other source's history is untouched — un-merge reverses one thing.
|
|
assert "**Merged from:** #3" in kwargs["body"]
|
|
|
|
|
|
async def test_unmerging_the_last_source_clears_the_provenance_line():
|
|
survivor = _survivor([loc(path="a.py", repo="r")], [{"id": 2, "locations": [], "tags": ["t"]}])
|
|
kwargs = await _run_unmerge(survivor)
|
|
assert "Merged from" not in kwargs["body"]
|
|
|
|
|
|
# --- restoring the source -------------------------------------------------
|
|
|
|
|
|
async def test_an_already_restored_source_is_repaired_not_refused():
|
|
"""The scenario that motivated the feature: the operator restored the source
|
|
from the trash by hand, so both records claim its call sites and nothing ever
|
|
stripped the survivor's copy. Un-merge must fix that, not reject it."""
|
|
theirs = loc(path="theirs.py", repo="r")
|
|
survivor = _survivor([loc(path="mine.py", repo="r"), theirs],
|
|
[{"id": 2, "locations": [theirs]}])
|
|
alive = SimpleNamespace(id=2, user_id=7, note_type="snippet", deleted_at=None,
|
|
title="g — x", tags=["snippet"], body="", data=None)
|
|
with patch("scribe.services.trash.restore_entity", AsyncMock()) as revive:
|
|
kwargs = await _run_unmerge(survivor, source_alive=alive)
|
|
# Nothing to revive — it's already alive — but the subtraction still happens.
|
|
revive.assert_not_called()
|
|
assert kwargs["data"]["locations"] == [loc(path="mine.py", repo="r")]
|
|
|
|
|
|
async def test_a_purged_source_is_refused_and_the_survivor_is_untouched():
|
|
"""If the source can't come back, stripping the survivor would lose the
|
|
locations entirely — no record would claim them."""
|
|
survivor = _survivor([loc(path="a.py", repo="r")], [{"id": 2, "locations": [loc(path="a.py", repo="r")]}])
|
|
|
|
async def fake_get(_uid, sid):
|
|
return survivor if sid == 1 else None
|
|
|
|
with (
|
|
patch.object(s, "get_snippet", AsyncMock(side_effect=fake_get)),
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)),
|
|
patch("scribe.services.trash.restore_entity", AsyncMock(return_value=None)),
|
|
patch.object(s.notes_svc, "update_note", AsyncMock()) as upd,
|
|
):
|
|
with pytest.raises(s.UnmergeError, match="purged"):
|
|
await s.unmerge_snippet(7, 1, 2)
|
|
upd.assert_not_called()
|
|
|
|
|
|
# --- refusals -------------------------------------------------------------
|
|
|
|
|
|
async def test_an_entry_without_attribution_is_refused_not_guessed():
|
|
"""Bare-id provenance comes from parsing the body, which can only hold ids.
|
|
Subtracting a guess could strip call sites the survivor owns — so refuse and
|
|
say what to do instead."""
|
|
survivor = _survivor([loc(path="a.py", repo="r")], [2])
|
|
|
|
async def fake_get(_uid, sid):
|
|
return survivor if sid == 1 else None
|
|
|
|
with (
|
|
patch.object(s, "get_snippet", AsyncMock(side_effect=fake_get)),
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)),
|
|
patch.object(s.notes_svc, "update_note", AsyncMock()) as upd,
|
|
):
|
|
with pytest.raises(s.UnmergeError, match="provenance"):
|
|
await s.unmerge_snippet(7, 1, 2)
|
|
upd.assert_not_called()
|
|
|
|
|
|
async def test_unmerging_something_never_absorbed_is_refused():
|
|
survivor = _survivor([loc(path="a.py", repo="r")], [{"id": 99, "locations": []}])
|
|
with (
|
|
patch.object(s, "get_snippet", AsyncMock(return_value=survivor)),
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)),
|
|
):
|
|
with pytest.raises(s.UnmergeError, match="no record of absorbing"):
|
|
await s.unmerge_snippet(7, 1, 2)
|
|
|
|
|
|
async def test_unmerge_requires_write_access():
|
|
"""Same rule as merge: a read-only share can see the record, not rearrange it."""
|
|
survivor = _survivor([loc(path="a.py", repo="r")], [{"id": 2, "locations": []}])
|
|
with (
|
|
patch.object(s, "get_snippet", AsyncMock(return_value=survivor)),
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=False)),
|
|
):
|
|
with pytest.raises(PermissionError):
|
|
await s.unmerge_snippet(7, 1, 2)
|
|
|
|
|
|
async def test_unmerge_on_a_missing_survivor_returns_none():
|
|
with patch.object(s, "get_snippet", AsyncMock(return_value=None)):
|
|
assert await s.unmerge_snippet(7, 1, 2) is None
|