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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user