fix(scribe): a delete must not depend on the lookup that names it (#3273)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m15s
CI & Build / Build & push image (push) Successful in 36s

CI caught the naming work reaching for Postgres from the unit lane, and the
connection error was the symptom of a real design fault rather than a test
gap: reading the title BEFORE the delete put a live query on the delete path,
so a lookup that failed would have stopped the delete happening.

That is a decoration breaking its payload — the same mistake just fixed in
rules_etag, made again two commits later. Every title lookup now fails open:
delete_task, delete_note, delete_milestone, delete_snippet and rule_history
lose the name, never the operation.

The five unit tests mock the lookup rather than reaching for a database, and
delete_note gains one asserting the delete still happens when the lookup
raises — the behaviour, not just the absence of a crash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 13:17:52 -04:00
co-authored by Claude Opus 5
parent efabba58dd
commit 7985f8c7d7
8 changed files with 89 additions and 13 deletions
+29 -1
View File
@@ -1,5 +1,5 @@
"""Tests for fable_*_note tools."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -294,12 +294,37 @@ async def test_update_note_raises_when_not_found():
@pytest.mark.asyncio
async def test_delete_note_soft_deletes_and_returns_batch():
doomed = MagicMock()
doomed.title = "the note that went"
with patch(
"scribe.mcp.tools.notes.trash_svc.delete",
AsyncMock(return_value="batch-1"),
), patch(
"scribe.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(return_value=(doomed, "owner")),
):
result = await delete_note(note_id=7)
assert result["deleted_batch_id"] == "batch-1"
# The confirmation NAMES what went (#3273). After the delete the row is
# trashed, so this line is the last chance to say which note it was.
assert result["title"] == "the note that went"
assert "the note that went" in result["message"]
@pytest.mark.asyncio
async def test_delete_note_still_deletes_when_the_title_lookup_fails():
"""The title is a courtesy on top of the delete, never a precondition for
it. A lookup that errors costs the name, not the operation."""
with patch(
"scribe.mcp.tools.notes.trash_svc.delete",
AsyncMock(return_value="batch-1"),
), patch(
"scribe.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(side_effect=RuntimeError("database down")),
):
result = await delete_note(note_id=7)
assert result["deleted_batch_id"] == "batch-1"
assert result["title"] == ""
@pytest.mark.asyncio
@@ -307,6 +332,9 @@ async def test_delete_note_raises_when_not_found():
with patch(
"scribe.mcp.tools.notes.trash_svc.delete",
AsyncMock(return_value=None),
), patch(
"scribe.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="note 999 not found"):
await delete_note(note_id=999)
+10
View File
@@ -470,6 +470,11 @@ async def test_rule_history_lists_without_the_heavy_text():
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
AsyncMock(return_value=[_fake_version(), _fake_version(id=4)]),
), patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=fake_rule(
id=100, title="The runner has no bash", statement="s", topic_id=10,
)),
):
from scribe.mcp.tools.rulebooks import rule_history
out = await rule_history(rule_id=100)
@@ -503,6 +508,11 @@ async def test_rule_history_says_an_empty_history_is_ordinary():
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
AsyncMock(return_value=[]),
), patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=fake_rule(
id=100, title="The runner has no bash", statement="s", topic_id=10,
)),
):
from scribe.mcp.tools.rulebooks import rule_history
out = await rule_history(rule_id=100)
+12 -3
View File
@@ -138,9 +138,18 @@ async def test_merge_snippets_read_only_target_says_why():
@pytest.mark.asyncio
async def test_delete_snippet_retires_or_raises():
from scribe.mcp.tools.snippets import delete_snippet
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=True)):
assert await delete_snippet(1) == {"deleted": True, "id": 1}
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=False)):
doomed = MagicMock()
doomed.title = "debounce helper"
# The confirmation names what went (#3273) — after the delete there is
# nothing left to look the title up from.
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=True)), \
patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=doomed)):
assert await delete_snippet(1) == {
"deleted": True, "id": 1, "title": "debounce helper",
}
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=False)), \
patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=None)):
with pytest.raises(ValueError):
await delete_snippet(404)