Merge scene saves as per-id deltas instead of clobbering
PUT now takes {changed, deleted} and merges per annotation id, so concurrent
edits to different annotations both survive; same-annotation edits are
last-write-wins by arrival. No version/locking. No-op saves don't write a
revision. Tests cover merge, LWW, stale-client safety, attribution
(server-stamped/spoof-resistant, creator preserved), revisions, and access.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
36054ac329
commit
60fb4df795
2 changed files with 115 additions and 9 deletions
|
|
@ -1,3 +1,97 @@
|
|||
from django.test import TestCase
|
||||
import json
|
||||
|
||||
# Create your tests here.
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
|
||||
from scenes.models import Project, Revision
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def ann(name, content="", **extra):
|
||||
return {"type": "annotation", "parent": "root", "name": name,
|
||||
"content": content, "marks": [], **extra}
|
||||
|
||||
|
||||
class SceneApiTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.alice = User.objects.create_user("alice", password="pw")
|
||||
self.bob = User.objects.create_user("bob", password="pw")
|
||||
self.project = Project.objects.create(owner=self.alice, name="p")
|
||||
self.project.collaborators.add(self.bob)
|
||||
self.a = Client(); self.a.login(username="alice", password="pw")
|
||||
self.b = Client(); self.b.login(username="bob", password="pw")
|
||||
|
||||
def put(self, client, **body):
|
||||
return client.put(f"/api/projects/{self.project.pk}/scene/",
|
||||
json.dumps(body), content_type="application/json")
|
||||
|
||||
def groups(self):
|
||||
self.project.refresh_from_db()
|
||||
return self.project.scene.get("groups", {})
|
||||
|
||||
|
||||
class DeltaMergeTests(SceneApiTestCase):
|
||||
def test_different_annotations_merge_without_clobber(self):
|
||||
self.put(self.a, changed={"a1": ann("opening")})
|
||||
self.put(self.b, changed={"a2": ann("beat")})
|
||||
self.assertEqual(set(self.groups()), {"a1", "a2"})
|
||||
|
||||
def test_same_annotation_is_last_write_wins(self):
|
||||
self.put(self.a, changed={"a1": ann("x", "first")})
|
||||
self.put(self.b, changed={"a1": ann("x", "second")})
|
||||
self.assertEqual(self.groups()["a1"]["content"], "second")
|
||||
|
||||
def test_stale_client_neither_resurrects_nor_wipes(self):
|
||||
# bob only ever knew a2; his delta must not erase alice's a1
|
||||
self.put(self.a, changed={"a1": ann("x")})
|
||||
self.put(self.b, changed={"a2": ann("y")})
|
||||
self.assertEqual(set(self.groups()), {"a1", "a2"})
|
||||
|
||||
def test_delete_is_explicit_and_scoped(self):
|
||||
self.put(self.a, changed={"a1": ann("x"), "a2": ann("y")})
|
||||
self.put(self.b, deleted=["a1"])
|
||||
self.assertEqual(set(self.groups()), {"a2"})
|
||||
|
||||
|
||||
class AttributionTests(SceneApiTestCase):
|
||||
def test_server_stamps_creator_ignoring_client(self):
|
||||
self.put(self.a, changed={"a1": ann("x", createdBy="hacker", editedBy="hacker")})
|
||||
g = self.groups()["a1"]
|
||||
self.assertEqual((g["createdBy"], g["editedBy"]), ("alice", "alice"))
|
||||
|
||||
def test_edit_preserves_creator_updates_editor(self):
|
||||
self.put(self.a, changed={"a1": ann("x")})
|
||||
self.put(self.b, changed={"a1": ann("x", "edited")})
|
||||
g = self.groups()["a1"]
|
||||
self.assertEqual(g["createdBy"], "alice")
|
||||
self.assertEqual(g["editedBy"], "bob")
|
||||
|
||||
def test_unchanged_annotation_is_not_restamped(self):
|
||||
self.put(self.a, changed={"a1": ann("x")})
|
||||
before = self.groups()["a1"]["editedAt"]
|
||||
self.put(self.b, changed={"a1": ann("x")}) # identical content
|
||||
after = self.groups()["a1"]
|
||||
self.assertEqual(after["editedAt"], before)
|
||||
self.assertEqual(after["editedBy"], "alice")
|
||||
|
||||
def test_revision_per_real_change_only(self):
|
||||
self.put(self.a, changed={"a1": ann("x")})
|
||||
self.put(self.b, changed={"a1": ann("x")}) # no-op → no revision
|
||||
self.put(self.a, deleted=["a1"])
|
||||
revs = Revision.objects.filter(project=self.project).order_by("created")
|
||||
self.assertEqual([r.summary for r in revs],
|
||||
["+1 ~0 −0 annotations", "+0 ~0 −1 annotations"])
|
||||
self.assertEqual(revs[0].user, self.alice)
|
||||
|
||||
|
||||
class AccessTests(SceneApiTestCase):
|
||||
def test_write_requires_authentication(self):
|
||||
r = Client().put(f"/api/projects/{self.project.pk}/scene/",
|
||||
json.dumps({"changed": {}}), content_type="application/json")
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
||||
def test_non_collaborator_cannot_edit(self):
|
||||
User.objects.create_user("carol", password="pw")
|
||||
carol = Client(); carol.login(username="carol", password="pw")
|
||||
self.assertEqual(self.put(carol, changed={"a1": ann("x")}).status_code, 404)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue