Projects live at URLs, have owners, and are edited together live
A project is only ever at /p/<id>/<slug>; / is the index of the projects
you own or edit. Every project has an owner, who can name editors;
anyone with the link can view. Every edit saves itself, one request in
flight at a time, as a patch of the leaves that changed, and a websocket
(channels + daphne) carries presence and each committed write to
everyone else in the project. The first write to a leaf wins, and the
loser is told.
Undo is per person: a step undoes only if the leaves it touched still
hold what it left, so it never takes a collaborator's work with it.
Named snapshots replace saving, and restore as an ordinary write.
An empty symbol now survives the leaf round trip with `:nodes {}`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c17ee138f2
commit
6a53adb5e0
31 changed files with 1918 additions and 158 deletions
|
|
@ -361,7 +361,10 @@ class DocumentTests(TestCase):
|
|||
"""Tier 1: load, save, and the conditional write."""
|
||||
|
||||
def setUp(self):
|
||||
self.project = Project.objects.create(name="a project")
|
||||
from django.contrib.auth import get_user_model
|
||||
owner = get_user_model().objects.create_user("owner", password="password1")
|
||||
self.client.force_login(owner)
|
||||
self.project = Project.objects.create(name="a project", owner=owner)
|
||||
descriptor = analysis_descriptor()
|
||||
self.analysis = key_for(descriptor)
|
||||
self.client.post("/api/analyses", data=json.dumps(
|
||||
|
|
@ -523,6 +526,61 @@ class DocumentTests(TestCase):
|
|||
|
||||
# --- revisions ---------------------------------------------------------
|
||||
|
||||
def patch(self, base, leaves, removed=()):
|
||||
return self.put(f"/api/projects/{self.project.id}", {
|
||||
"base": base,
|
||||
"clips": [{"cid": "c1", "analysis": self.analysis, "leaves": leaves,
|
||||
"removed": list(removed), "blocks": [self.block]}],
|
||||
})
|
||||
|
||||
def test_a_patch_leaves_what_it_does_not_name_alone(self):
|
||||
seq = self.save().json()["seq"]
|
||||
response = self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]},
|
||||
removed=["clip/c1/symbol/main/node/mouth"])
|
||||
self.assertEqual(200, response.status_code, response.content)
|
||||
self.assertEqual(["clip/c1/timing"], response.json()["written"])
|
||||
self.assertEqual(4, Leaf.objects.count())
|
||||
|
||||
def test_two_people_on_different_leaves_both_land(self):
|
||||
seq = self.save().json()["seq"]
|
||||
self.assertEqual(200, self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]}).status_code)
|
||||
# The second saver has not caught up, and touched a different leaf.
|
||||
response = self.patch(seq, {"clip/c1/symbol/main": ["^ ", "~:frames", 12]})
|
||||
self.assertEqual(200, response.status_code, response.content)
|
||||
leaves = self.client.get(f"/api/projects/{self.project.id}").json()["clips"][0]["leaves"]
|
||||
self.assertEqual(["^ ", "~:fps", 24], leaves["clip/c1/timing"])
|
||||
self.assertEqual(["^ ", "~:frames", 12], leaves["clip/c1/symbol/main"])
|
||||
|
||||
def test_two_people_on_one_leaf_is_a_conflict_that_writes_nothing(self):
|
||||
seq = self.save().json()["seq"]
|
||||
self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]})
|
||||
response = self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 12],
|
||||
"clip/c1/symbol/main": ["^ ", "~:frames", 12]})
|
||||
self.assertEqual(409, response.status_code)
|
||||
self.assertEqual({"clip/c1/timing": ["^ ", "~:fps", 24]}, response.json()["conflicts"])
|
||||
self.assertEqual(seq + 1, Project.objects.get(id=self.project.id).seq)
|
||||
self.assertEqual(["^ ", "~:frames", 48],
|
||||
Leaf.objects.get(path="clip/c1/symbol/main").value)
|
||||
# Caught up to their seq, the same write is ordinary.
|
||||
self.assertEqual(200, self.patch(seq + 1, {"clip/c1/timing": ["^ ", "~:fps", 12]}).status_code)
|
||||
|
||||
def test_a_named_snapshot_restores_as_an_ordinary_write(self):
|
||||
self.save()
|
||||
snap = self.client.post(f"/api/projects/{self.project.id}/revisions",
|
||||
data=json.dumps({"summary": "before the big change"}),
|
||||
content_type="application/json").json()
|
||||
moved = self.leaves()
|
||||
moved["clip/c1/timing"] = ["^ ", "~:fps", 12]
|
||||
del moved["clip/c1/symbol/main/node/mouth"]
|
||||
self.save(moved)
|
||||
listed = self.client.get(f"/api/projects/{self.project.id}/revisions").json()["revisions"]
|
||||
self.assertEqual(["before the big change"], [r["summary"] for r in listed])
|
||||
restored = self.client.post(
|
||||
f"/api/projects/{self.project.id}/revisions/{snap['id']}/restore").json()
|
||||
self.assertEqual(2, restored["changed"])
|
||||
leaves = self.client.get(f"/api/projects/{self.project.id}").json()["clips"][0]["leaves"]
|
||||
self.assertEqual(self.leaves(), leaves)
|
||||
|
||||
def test_a_revision_snapshots_the_authored_layer(self):
|
||||
self.save()
|
||||
response = self.client.post(
|
||||
|
|
@ -826,3 +884,99 @@ class UploadTests(TestCase):
|
|||
digest="e" * 64, fps=12, frames=3, width=8, height=6, audio=blob)
|
||||
manifest = self.client.get(f"/api/footage/{footage.id}").json()
|
||||
self.assertIsNone(manifest["video"])
|
||||
|
||||
|
||||
@override_settings(BLOB_ROOT=BLOB_DIR)
|
||||
class OwnershipTests(TestCase):
|
||||
"""Anyone with the link reads; the owner and the editors write."""
|
||||
|
||||
def setUp(self):
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
self.ann = User.objects.create_user("ann", password="password1")
|
||||
self.bob = User.objects.create_user("bob", password="password1")
|
||||
self.project = Project.objects.create(name="ann's", owner=self.ann)
|
||||
|
||||
def write(self):
|
||||
return self.client.put(f"/api/projects/{self.project.id}",
|
||||
data=json.dumps({"name": "renamed", "clips": []}),
|
||||
content_type="application/json")
|
||||
|
||||
def test_anyone_with_the_link_can_read_and_nobody_else_can_write(self):
|
||||
loaded = self.client.get(f"/api/projects/{self.project.id}").json()
|
||||
self.assertEqual(("ann", False), (loaded["owner"], loaded["can_edit"]))
|
||||
self.assertEqual(403, self.write().status_code)
|
||||
self.client.login(username="bob", password="password1")
|
||||
self.assertEqual(403, self.write().status_code)
|
||||
|
||||
def test_the_owner_names_an_editor_who_can_then_write(self):
|
||||
self.client.login(username="bob", password="password1")
|
||||
self.assertEqual(403, self.client.post(
|
||||
f"/api/projects/{self.project.id}/editors", data=json.dumps({"username": "bob"}),
|
||||
content_type="application/json").status_code)
|
||||
self.client.login(username="ann", password="password1")
|
||||
self.assertEqual(200, self.write().status_code)
|
||||
self.assertEqual(["bob"], self.client.post(
|
||||
f"/api/projects/{self.project.id}/editors", data=json.dumps({"username": "bob"}),
|
||||
content_type="application/json").json()["editors"])
|
||||
self.client.login(username="bob", password="password1")
|
||||
self.assertTrue(self.client.get(f"/api/projects/{self.project.id}").json()["can_edit"])
|
||||
self.assertEqual(200, self.write().status_code)
|
||||
self.client.login(username="ann", password="password1")
|
||||
self.client.delete(f"/api/projects/{self.project.id}/editors/bob")
|
||||
self.client.login(username="bob", password="password1")
|
||||
self.assertEqual(403, self.write().status_code)
|
||||
|
||||
def test_a_project_is_made_by_somebody_signed_in_and_is_theirs(self):
|
||||
self.assertEqual(403, self.client.post("/api/projects", data=json.dumps({"name": "x"}),
|
||||
content_type="application/json").status_code)
|
||||
self.client.post("/api/signup", data=json.dumps(
|
||||
{"username": "cat", "password": "password1"}), content_type="application/json")
|
||||
self.assertEqual("cat", self.client.get("/api/me").json()["username"])
|
||||
mine = self.client.post("/api/projects", data=json.dumps({"name": "y"}),
|
||||
content_type="application/json").json()
|
||||
self.assertEqual(("cat", True), (mine["owner"], mine["can_edit"]))
|
||||
listed = {p["name"] for p in self.client.get("/api/projects").json()["projects"]}
|
||||
self.assertEqual({"y"}, listed)
|
||||
self.client.logout()
|
||||
self.assertEqual([], self.client.get("/api/projects").json()["projects"])
|
||||
|
||||
def test_a_project_has_an_address(self):
|
||||
response = self.client.get(f"/p/{self.project.id}")
|
||||
self.assertEqual(200, response.status_code)
|
||||
# The slug is the name, for people; the id is what finds it.
|
||||
self.assertEqual(200, self.client.get(f"/p/{self.project.id}/anything-at-all").status_code)
|
||||
self.assertContains(response, 'id="app"')
|
||||
|
||||
|
||||
class SocketTests(TestCase):
|
||||
"""A committed write reaches everyone in the room; presence is stamped."""
|
||||
|
||||
def test_a_save_is_broadcast_to_the_room(self):
|
||||
from asgiref.sync import async_to_sync, sync_to_async
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from clips.consumers import broadcast
|
||||
from server.asgi import application
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
project = Project.objects.create(
|
||||
name="shared", owner=get_user_model().objects.create_user("host"))
|
||||
|
||||
async def scenario():
|
||||
peer = WebsocketCommunicator(application, f"/ws/projects/{project.id}",
|
||||
headers=[(b"origin", b"http://localhost")])
|
||||
connected, _ = await peer.connect()
|
||||
self.assertTrue(connected)
|
||||
self.assertEqual("welcome", (await peer.receive_json_from())["kind"])
|
||||
self.assertEqual([], (await peer.receive_json_from())["peers"])
|
||||
self.assertEqual("join", (await peer.receive_json_from())["kind"])
|
||||
# The server stamps who sent it; a claimed name is overwritten.
|
||||
await peer.send_json_to({"kind": "state", "frame": 12, "user": "forged"})
|
||||
state = await peer.receive_json_from()
|
||||
self.assertEqual((12, None), (state["frame"], state["user"]))
|
||||
await sync_to_async(broadcast)(project.id, {"seq": 1, "clips": []})
|
||||
delta = await peer.receive_json_from()
|
||||
self.assertEqual(("delta", 1), (delta["kind"], delta["seq"]))
|
||||
await peer.disconnect()
|
||||
|
||||
async_to_sync(scenario)()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue