diff --git a/scenes/tests.py b/scenes/tests.py index 7ce503c..b2b8d72 100644 --- a/scenes/tests.py +++ b/scenes/tests.py @@ -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) diff --git a/scenes/views.py b/scenes/views.py index ba85e9d..19360d1 100644 --- a/scenes/views.py +++ b/scenes/views.py @@ -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()) - Revision.objects.create(project=project, user=request.user, - summary=summary, - annotations=annotation_layer(project.scene)) + 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"])