sync: recipients keep their own pin, archive and place on shared notes
A note_user_state row per (note, recipient) holds what used to be the owner's columns as far as anyone else could tell. The board filters and orders through the viewer's own state; PATCH and reorder write it for a note shared at any level; the feed's revision for a shared note is the later of the note's and the caller's row, so a recipient's pin reaches their devices and no one else's. Push takes the three with their own `state_at` stamp (protocol 7, `shared_state`), so pinning a copy whose text is behind never makes that text win over the owner's edit. A body is only stamped as an edit when it changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,7 @@ pytestmark = pytest.mark.integration
|
||||
|
||||
# Every table the tests touch, child-first so FKs never block the truncate.
|
||||
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
|
||||
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, invites, password_resets, users"
|
||||
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, invites, password_resets, users"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -1292,9 +1292,9 @@ async def test_a_view_share_writes_nothing_and_an_edit_share_writes_only_text(ap
|
||||
assert "fromguest" in owner_tags
|
||||
assert (await (await recipient.get("/api/labels")).get_json())["labels"] == []
|
||||
|
||||
# Everything else stays the owner's.
|
||||
assert (await recipient.patch(f"/api/notes/{nid}", json={"pinned": True})).status_code == 403
|
||||
assert (await recipient.patch(f"/api/notes/{nid}", json={"body": "x", "archived": True})).status_code == 403
|
||||
# Everything else but their own pin and archive (#5176) stays the owner's.
|
||||
assert (await recipient.patch(f"/api/notes/{nid}", json={"remind_at": "2099-01-01T00:00:00Z"})).status_code == 403
|
||||
assert (await recipient.patch(f"/api/notes/{nid}", json={"body": "x", "recurrence": "daily"})).status_code == 403
|
||||
assert (await recipient.post(f"/api/notes/{nid}/trash")).status_code == 404
|
||||
assert (await recipient.get(f"/api/notes/{nid}/revisions")).status_code == 404
|
||||
|
||||
@@ -1303,6 +1303,41 @@ async def test_a_view_share_writes_nothing_and_an_edit_share_writes_only_text(ap
|
||||
assert "- [x] milk" in body and "eggs" in body
|
||||
|
||||
|
||||
async def test_owner_and_recipient_each_pin_archive_and_order_their_own(app_client, db):
|
||||
recipient, stranger, people = await _three_people(app_client)
|
||||
older = await _owners_note(app_client, "older")
|
||||
nid = await _owners_note(app_client, "newer")
|
||||
for note_id in (older, nid):
|
||||
await _share(app_client, note_id, people["recipient"], "view")
|
||||
|
||||
async def pinned(client) -> bool:
|
||||
return (await (await client.get(f"/api/notes/{nid}")).get_json())["pinned"]
|
||||
|
||||
# A view share is enough: pinning is organizing your own board, not changing the note.
|
||||
mine = await recipient.patch(f"/api/notes/{nid}", json={"pinned": True})
|
||||
assert mine.status_code == 200, await mine.get_data(as_text=True)
|
||||
assert (await mine.get_json())["pinned"] is True
|
||||
assert (await pinned(recipient), await pinned(app_client)) == (True, False)
|
||||
await app_client.patch(f"/api/notes/{nid}", json={"pinned": True})
|
||||
await recipient.patch(f"/api/notes/{nid}", json={"pinned": False})
|
||||
assert (await pinned(recipient), await pinned(app_client)) == (False, True)
|
||||
assert (await stranger.patch(f"/api/notes/{nid}", json={"pinned": True})).status_code == 404
|
||||
|
||||
# Archived by the recipient, it leaves their board and never the owner's.
|
||||
await recipient.patch(f"/api/notes/{nid}", json={"archived": True})
|
||||
assert await _board_ids(recipient) == [older]
|
||||
assert await _board_ids(recipient, "filter=archived") == [nid]
|
||||
assert nid in await _board_ids(app_client)
|
||||
await recipient.patch(f"/api/notes/{nid}", json={"archived": False})
|
||||
|
||||
# Until they move them, a recipient sees the owner's order; after, their own.
|
||||
await app_client.patch(f"/api/notes/{nid}", json={"pinned": False})
|
||||
assert await _board_ids(recipient) == [nid, older]
|
||||
assert (await recipient.post("/api/notes/reorder", json={"ids": [older, nid]})).status_code == 200
|
||||
assert await _board_ids(recipient) == [older, nid]
|
||||
assert await _board_ids(app_client) == [nid, older]
|
||||
|
||||
|
||||
async def test_unsharing_trashing_and_deleting_each_end_access(app_client, db):
|
||||
recipient, _, people = await _three_people(app_client)
|
||||
nid = await _owners_note(app_client)
|
||||
@@ -1420,6 +1455,45 @@ async def test_an_edit_share_pushes_text_and_nothing_else(app_client, db):
|
||||
assert (await app_client.get(f"/api/notes/{nid}")).status_code == 200
|
||||
|
||||
|
||||
async def test_a_recipients_own_state_syncs_to_their_devices_only(app_client, db):
|
||||
recipient, _, people = await _three_people(app_client)
|
||||
nid = await _owners_note(app_client, "first line")
|
||||
await _share(app_client, nid, people["recipient"], "edit")
|
||||
start = (await _feed(recipient))["cursor"]
|
||||
|
||||
async def push(change: dict) -> dict:
|
||||
payload = {"changes": [{"entity": "note", "id": nid, "op": "upsert", **change}]}
|
||||
return (await (await recipient.post("/api/sync/push", json=payload)).get_json())["results"][0]
|
||||
|
||||
# The owner edits; the recipient's phone, a version behind, pins its stale copy.
|
||||
await app_client.patch(f"/api/notes/{nid}", json={"body": "first line, the owner's edit"})
|
||||
owners = (await _feed(app_client))["cursor"]
|
||||
pinned = await push(
|
||||
{
|
||||
"edited_at": "2000-01-01T00:00:00Z",
|
||||
"body": "first line",
|
||||
"pinned": True,
|
||||
"archived": False,
|
||||
"position": 7,
|
||||
"state_at": "2099-01-01T00:00:00Z",
|
||||
}
|
||||
)
|
||||
assert pinned["status"] == "applied", pinned
|
||||
# The pin's newer time is the state's alone, so the stale text lost to the edit.
|
||||
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["body"] == "first line, the owner's edit"
|
||||
|
||||
held = _feed_note(await _feed(recipient, start), nid)
|
||||
assert (held["pinned"], held["position"], held["sync_revision"]) == (True, 7, pinned["sync_revision"])
|
||||
# Nothing about the note changed for its owner, so their devices aren't sent it.
|
||||
assert _feed_note(await _feed(app_client, owners), nid) is None
|
||||
assert _feed_note(await _feed(app_client), nid)["pinned"] is False
|
||||
|
||||
# Another device's older unpin loses to the newer pin.
|
||||
stale = await push({"edited_at": "2000-01-01T00:00:00Z", "pinned": False, "state_at": "2098-01-01T00:00:00Z"})
|
||||
assert stale["status"] == "kept"
|
||||
assert (await (await recipient.get(f"/api/notes/{nid}")).get_json())["pinned"] is True
|
||||
|
||||
|
||||
async def test_deleting_a_shared_note_reaches_the_recipients_devices(app_client, db):
|
||||
recipient, _, people = await _three_people(app_client)
|
||||
nid = await _owners_note(app_client)
|
||||
|
||||
Reference in New Issue
Block a user