tl/scenes/tests.py
Your Name 60fb4df795 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>
2026-06-29 01:34:52 -04:00

97 lines
4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
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)