tests: every note-text write path is pinned, and two of them fail today

Integration tests for the edit sequence at each door: create, PATCH, ticking an
item, restoring a revision and sync push. Two fail on today's code, on purpose:

- restoring a revision never queues link previews, so a restored link stays a
  bare URL (#5164, audit B5);
- a pushed note whose standalone #tag gets lifted stores the server's clock as
  its edit time instead of the client's, because the lift's second flush lets
  the column's onupdate overwrite it.

The fix follows in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 21:26:15 -04:00
co-authored by Claude Opus 5.5
parent 4d61b34b85
commit 1a2f71e381
+107
View File
@@ -630,3 +630,110 @@ async def test_creating_a_tag_that_differs_only_in_case_returns_the_existing_one
assert (await second.get_json())["id"] == (await first.get_json())["id"]
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert len(listing) == 1
# --- One save path for a note's text (#5164) ---------------------------------
#
# A body edit is a sequence: keep a revision, rename the note, lift #tags, commit,
# queue link previews. It was written out once per route, and the copies drifted —
# restoring a revision skipped the previews. These pin the sequence at each door.
async def _signed_in(app_client, who: str):
reg = await app_client.post(
"/api/auth/register",
json={"email": f"{who}@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
def _record_previews(monkeypatch, module: str) -> list[tuple[str, str]]:
"""Capture what a write path queues for unfurling, instead of fetching."""
queued: list[tuple[str, str]] = []
monkeypatch.setattr(f"{module}.schedule_unfurls", lambda nid, body: queued.append((str(nid), body)))
return queued
async def test_restoring_a_revision_queues_previews_for_its_links(app_client, db, monkeypatch):
"""The bug that motivated the shared path: restore was the one copy of the edit
sequence without the unfurl step, so a link brought back by a restore stayed a
bare URL on every surface."""
await _signed_in(app_client, "restore")
queued = _record_previews(monkeypatch, "inkwell.notes")
note = await (await app_client.post("/api/notes", json={"body": "read https://example.com/a"})).get_json()
await app_client.patch(f"/api/notes/{note['id']}", json={"body": "no link any more"})
revisions = (await (await app_client.get(f"/api/notes/{note['id']}/revisions")).get_json())["revisions"]
assert [r["body"] for r in revisions] == ["read https://example.com/a"]
queued.clear()
resp = await app_client.post(f"/api/notes/{note['id']}/revisions/{revisions[0]['id']}/restore")
assert resp.status_code == 200
assert (await resp.get_json())["body"] == "read https://example.com/a"
assert queued == [(note["id"], "read https://example.com/a")], "the restored link gets its preview"
async def test_restoring_keeps_the_text_it_replaces(app_client, db):
"""Restore snapshots unconditionally — inside an editing session too — so a
restore can itself be undone."""
await _signed_in(app_client, "undo")
note = await (await app_client.post("/api/notes", json={"body": "first"})).get_json()
await app_client.patch(f"/api/notes/{note['id']}", json={"body": "second"})
rev = (await (await app_client.get(f"/api/notes/{note['id']}/revisions")).get_json())["revisions"][0]
await app_client.post(f"/api/notes/{note['id']}/revisions/{rev['id']}/restore")
bodies = [r["body"] for r in (await (await app_client.get(f"/api/notes/{note['id']}/revisions")).get_json())["revisions"]]
assert sorted(bodies) == ["first", "second"]
async def test_each_route_that_writes_text_queues_previews_only_when_it_changed(app_client, db, monkeypatch):
await _signed_in(app_client, "routes")
queued = _record_previews(monkeypatch, "inkwell.notes")
note = await (await app_client.post("/api/notes", json={"body": "https://example.com/new"})).get_json()
assert queued == [(note["id"], "https://example.com/new")], "create"
queued.clear()
await app_client.patch(f"/api/notes/{note['id']}", json={"pinned": True})
assert queued == [], "a pin is not a text change"
await app_client.patch(f"/api/notes/{note['id']}", json={"body": "https://example.com/new\n- [ ] milk"})
assert queued == [(note["id"], "https://example.com/new\n- [ ] milk")], "PATCH"
queued.clear()
await app_client.patch(f"/api/notes/{note['id']}/items/0", json={"checked": True})
assert queued == [(note["id"], "https://example.com/new\n- [x] milk")], "ticking an item"
async def test_a_pushed_note_is_named_tagged_and_queued_like_a_typed_one(app_client, db, monkeypatch):
await _signed_in(app_client, "push")
queued = _record_previews(monkeypatch, "inkwell.sync")
nid = str(uuid.uuid4())
resp = await app_client.post(
"/api/sync/push",
json={"changes": [{"entity": "note", "id": nid, "op": "upsert", "body": "Trip https://example.com/t\n#travel",
"edited_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z"}]},
)
assert (await resp.get_json())["results"][0]["status"] == "created"
note = await (await app_client.get(f"/api/notes/{nid}")).get_json()
assert note["body"] == "Trip https://example.com/t", "the standalone tag was lifted"
assert [lb["name"] for lb in note["labels"]] == ["travel"]
assert note["display_title"] == "Trip https://example.com/t"
assert queued == [(nid, "Trip https://example.com/t")], "queued with the text as stored"
async def test_a_pushed_edit_keeps_the_clients_edit_time_when_a_tag_is_lifted(app_client, db):
"""Last-write-wins compares edit times, so the stored one has to be the client's.
Lifting a tag rewrites the body in a second flush, and the column's onupdate
used to stamp the server's clock over the time the client sent."""
await _signed_in(app_client, "edited")
nid = str(uuid.uuid4())
await app_client.post(
"/api/sync/push",
json={"changes": [{"entity": "note", "id": nid, "op": "upsert", "body": "milk\n#groceries",
"edited_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z"}]},
)
note = await (await app_client.get(f"/api/notes/{nid}")).get_json()
assert datetime.fromisoformat(note["updated_at"]) == datetime(2026, 1, 1, tzinfo=timezone.utc)