diff --git a/tests/test_integration.py b/tests/test_integration.py index 0be5bb2..8b3435b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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)