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)
|
||||
|
|
|
|||
|
|
@ -45,19 +45,31 @@ def scene(request, pk):
|
|||
if request.method == "GET":
|
||||
return JsonResponse({"fps": project.fps, "scene": project.scene})
|
||||
if request.method == "PUT":
|
||||
# A delta, not the whole scene: {changed: {gid: annotation}, deleted: [gid]}.
|
||||
# Merging per-id means two users editing different annotations both land
|
||||
# (no clobber); same-annotation edits are last-write-wins by arrival.
|
||||
data = json.loads(request.body or "{}")
|
||||
changed = data.get("changed") or {}
|
||||
deleted = data.get("deleted") or []
|
||||
summary = None
|
||||
if "scene" in data:
|
||||
project.scene, summary, _ = apply_attribution(
|
||||
project.scene, data["scene"], request.user.get_username(),
|
||||
if changed or deleted:
|
||||
groups = dict((project.scene or {}).get("groups", {}))
|
||||
groups.update(changed)
|
||||
for gid in deleted:
|
||||
groups.pop(gid, None)
|
||||
merged = dict(project.scene or {}, groups=groups)
|
||||
project.scene, summary, counts = apply_attribution(
|
||||
project.scene, merged, request.user.get_username(),
|
||||
timezone.now().isoformat())
|
||||
if any(counts.values()): # don't log a no-op save
|
||||
Revision.objects.create(project=project, user=request.user,
|
||||
summary=summary,
|
||||
annotations=annotation_layer(project.scene))
|
||||
if "fps" in data:
|
||||
project.fps = data["fps"]
|
||||
project.save(update_fields=["scene", "fps", "updated"])
|
||||
return JsonResponse({"ok": True, "updated": project.updated, "summary": summary})
|
||||
return JsonResponse({"ok": True, "updated": project.updated, "summary": summary,
|
||||
"annotations": annotation_layer(project.scene)})
|
||||
return HttpResponseNotAllowed(["GET", "PUT"])
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue