aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_playlist_store.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_playlist_store.py')
-rw-r--r--packages/meshbay-hub/tests/test_playlist_store.py155
1 files changed, 155 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_playlist_store.py b/packages/meshbay-hub/tests/test_playlist_store.py
new file mode 100644
index 0000000..203033c
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_playlist_store.py
@@ -0,0 +1,155 @@
+"""
+The playlist store: editing, and putting it on a node.
+
+The rules (`playlist-merge.js`) and the sealing (`playlist-crypto.js`) are pure
+and are executed by their own tests. `playlists.js` is neither — it is
+IndexedDB, WebCrypto and a transport, and node has no IndexedDB at all — so the
+shipped module is driven in Chrome against a node stubbed to record what it was
+handed. That stub is also the only way to check the thing that matters most:
+what leaves the browser is sealed.
+
+Four properties, and none of them is "it round-trips":
+
+ - a **stale node cannot lower** what this browser holds, because the local
+ copy is one of the merge inputs;
+ - a **deletion is not resurrected** by a node that still has the playlist;
+ - an **edit made on another device arrives**, body and all; and
+ - a sync with nothing to do **writes nothing**, because sync rides someone
+ else's connection and must not spend it.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "playlist_store_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "playlists.js").exists(),
+ reason="Chrome or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def steps():
+ proc = subprocess.run(["python3", str(HARNESS)],
+ capture_output=True, text=True, timeout=300)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, out
+ assert not out.get("logs"), f"the page logged: {out['logs']}"
+ return {s["step"]: s for s in out["steps"]}
+
+
+# ── editing ──────────────────────────────────────────────────────────────────
+
+def test_favourites_exists_without_having_been_created(steps):
+ """Never created by the user, materialised on first use, always first."""
+ listed = steps["after editing"]["list"]
+ assert listed[0]["id"] == "favorites"
+ assert [p["count"] for p in listed] == [1, 2]
+
+
+def test_a_stored_track_carries_what_the_player_needs_to_fetch_it(steps):
+ """`size` and `name` above all: without them the entry cannot be
+ downloaded at all, and the failure is invisible until playback."""
+ tracks = steps["tracks read back"]["tracks"]
+ assert [t["id"] for t in tracks] == ["hash-1", "hash-2"]
+ assert [t["size"] for t in tracks] == [1001, 1002]
+ assert [t["name"] for t in tracks] == ["1 - Titre.flac", "2 - Titre.flac"]
+ assert all(t["groupId"] == "g1" for t in tracks)
+ assert [t["title"] for t in tracks] == ["Titre 1", "Titre 2"]
+
+
+def test_favourites_is_a_toggle_and_an_ordinary_playlist_is_not(steps):
+ """Starring something twice leaves one star. An ordinary playlist takes the
+ same track twice, because real playlists do."""
+ assert steps["favourites is idempotent"]["added"] == 0
+ assert steps["favourites is idempotent"]["count"] == 1
+ assert steps["an ordinary playlist takes duplicates"]["count"] == 3
+
+
+def test_a_name_that_differs_only_by_case_or_accent_is_refused(steps):
+ """"Soirée" and "soiree" being two playlists is nobody's intention and very
+ easy to do by accident."""
+ assert steps["a folded duplicate name is refused"]["why"] == "duplicate name"
+
+
+def test_favourites_cannot_be_deleted(steps):
+ """Refused outright rather than special-cased, which is what keeps the
+ tombstone rule free of an exception — and a tombstone rule with an
+ exception is how the resurrection defect ships."""
+ assert steps["favourites cannot be deleted"]["why"] == (
+ "favorites cannot be deleted")
+
+
+# ── what leaves the browser ──────────────────────────────────────────────────
+
+def test_the_node_is_handed_one_blob_per_playlist_plus_a_manifest(steps):
+ s = steps["first sync"]
+ assert s["result"]["ok"] is True
+ assert s["kinds"][0].startswith("playlist:")
+ assert "playlist:favorites" in s["kinds"]
+ assert "playlists" in s["kinds"]
+
+
+def test_the_node_gets_ciphertext_and_nothing_else(steps):
+ """The claim, checked rather than asserted: the names are in the blob and
+ are not readable in it."""
+ s = steps["what the node holds"]
+ assert s["leaks"] == []
+ assert s["names"] == ["Favoris", "Soirée"], (
+ "the manifest must still open with the key that sealed it")
+
+
+def test_a_sync_with_nothing_to_do_writes_nothing(steps):
+ """Sync rides a connection opened for something else. The first version of
+ this pushed every body on every sync, because it compared against the
+ merged watermark rather than against what *that node* actually holds."""
+ assert steps["second sync is quiet"]["wrote"] == 0
+ assert steps["second sync is quiet"]["result"]["pushed"] == 0
+
+
+# ── nodes that are behind, ahead, or wrong ───────────────────────────────────
+
+def test_a_stale_node_cannot_lower_the_merged_state(steps):
+ """The rollback case. AEAD authenticates a blob; it does not stop a node
+ handing back an older one it still has. What does is that the local copy is
+ one of the merge inputs, so a stale node can only lose the tie."""
+ s = steps["a stale node cannot lower anything"]
+ assert s["tracks"] == 3, "a node with an older copy rolled the playlist back"
+ assert {p["id"]: p["count"] for p in s["list"]}["favorites"] == 1
+
+
+def test_an_edit_made_on_another_device_arrives_with_its_tracks(steps):
+ """The manifest says a playlist exists that this browser has never seen;
+ its body is then fetched because this node is ahead on that kind."""
+ s = steps["an edit made elsewhere arrives"]
+ assert s["list"] == ["Favoris", "Route", "Soirée"]
+ assert s["routeTracks"] == ["Route 1"], "the body never arrived"
+
+
+def test_a_node_that_still_holds_a_deleted_playlist_does_not_resurrect_it(steps):
+ """A node rehomed after three weeks. This is the single most likely defect
+ in the design and it looks like a sync working correctly."""
+ assert steps["a deletion is not resurrected"]["list"] == ["Favoris", "Route"]
+
+
+def test_a_deleted_playlists_body_is_reclaimed_from_the_node(steps):
+ """The tombstone in the manifest is what has to survive, not the tracks.
+ Without this the body of every playlist ever deleted stays on every node
+ for ever, and the account's 8 MB quota fills up with graves."""
+ assert steps["a deleted body is reclaimed"]["stillThere"] is False
+
+
+def test_a_session_from_before_the_hkdf_handle_degrades_rather_than_failing(steps):
+ """A bundle key loaded out of IndexedDB from before `deriveBundleKeys`
+ existed has no HKDF handle, and the passphrase is not in memory to
+ re-derive from. Playlists stay local until the next sign-in — reported,
+ rather than silently doing nothing."""
+ r = steps["a session from before the HKDF handle"]["result"]
+ assert r["ok"] is False and r["reason"] == "no_key"
+ assert r["pushed"] == 0