aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 16:07:18 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 16:07:18 +0200
commit2916aa376009283305a7acec4aafa3c96544499e (patch)
treebc19d010e8bebf39c175271564b3be01d1c06aa0 /packages/meshbay-hub/tests
parent25f62e169e049f0757ef611d5b409e7523958dee (diff)
downloadmeshbay-2916aa376009283305a7acec4aafa3c96544499e.tar.gz
playlists: make the writes actually leave the browser
Reported from a phone: signing in with the same account showed no playlists. syncWith was called from exactly one place in the interface, so creating a playlist, deleting one, removing a track and saving the queue all wrote to IndexedDB and stopped there. The store pushes itself now, coalesced, so a new mutation cannot forget to. Silence was the real defect. The node audited only successes, so a refusal left no trace and user_blob_list none at all; the background push swallowed its reason; the interface said nothing. All three report now, and "Sync now" says what happened either way. An unreadable blob on a node was treated as a fetch failure and returned before the push — permanent, once the node held anything. It is an absence: the client is the authority, and it gets overwritten. A sign-in reconciles whatever this browser already holds, a pending push is flushed when the page goes away, and a push that did not land is retried once. no_key is spelled out: a client that signs in with its remembered device key only ever has a bundle key persisted before the playlist subkey existed, and an AES handle is non-extractable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
-rwxr-xr-xpackages/meshbay-hub/tests/harness/playlist_store_probe.py160
-rw-r--r--packages/meshbay-hub/tests/test_playlist_store.py127
2 files changed, 287 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/playlist_store_probe.py b/packages/meshbay-hub/tests/harness/playlist_store_probe.py
index 8dfa4b1..f7573b7 100755
--- a/packages/meshbay-hub/tests/harness/playlist_store_probe.py
+++ b/packages/meshbay-hub/tests/harness/playlist_store_probe.py
@@ -203,7 +203,167 @@ function fakeNode() {
stillThere: holder.rows.has(bodyKind(eveningId)),
});
+ // ── every mutation reaches a node, not just the one that used to ──────
+ //
+ // The defect this exists for: `syncWith` was called from exactly one place
+ // in the interface, so creating a playlist, deleting one, removing a track
+ // and saving the queue all wrote to IndexedDB and stopped there. A second
+ // device saw nothing — which is the one thing playlists are for. Found in
+ // the node's own audit log: two events, ever.
+ const auto = fakeNode();
+ P.setPlaylistTransport(async () => auto);
+ const seen = () => auto.stored.map((s) => s.kind).sort();
+ const wait = () => new Promise((r) => setTimeout(r, 2300)); // > the debounce
+
+ const p1 = await P.createPlaylist(USER, 'Depuis le menu');
+ await wait();
+ steps.push({ step: 'create pushes', kinds: seen() });
+
+ auto.stored.length = 0;
+ await P.addTracks(USER, p1, [track(21)], 'g1');
+ await wait();
+ steps.push({ step: 'add pushes', kinds: seen() });
+
+ auto.stored.length = 0;
+ await P.removeTrackAt(USER, p1, 0);
+ await wait();
+ steps.push({ step: 'remove pushes', kinds: seen() });
+
+ auto.stored.length = 0;
+ const p2 = await P.saveQueueAsPlaylist(USER, 'Depuis la file', [track(31), track(32)]);
+ await wait();
+ steps.push({ step: 'save-queue pushes', kinds: seen() });
+
+ auto.stored.length = 0;
+ await P.deletePlaylist(USER, p2);
+ await wait();
+ steps.push({ step: 'delete pushes', kinds: seen(),
+ bodyGone: !auto.rows.has(bodyKind(p2)) });
+
+ // A burst is one push, not one per write.
+ auto.stored.length = 0;
+ for (let i = 0; i < 5; i++) await P.addTracks(USER, p1, [track(40 + i)], 'g1');
+ await wait();
+ steps.push({ step: 'a burst is coalesced', writes: auto.stored.length });
+
+ // ── a push that does not land is retried, once ────────────────────────
+ //
+ // Shortened from twenty seconds: a retry nobody can wait for is a retry
+ // nobody has checked, and this feature has already shipped one whole path
+ // that no test ever called.
+ P.setPushTimings({ debounceMs: 100, retryMs: 400 });
+ let refuse = true;
+ const flaky = fakeNode();
+ const flakyStore = flaky.storeUserBlob.bind(flaky);
+ flaky.storeUserBlob = async (...a) => {
+ if (refuse) throw new Error('Server busy, retry shortly');
+ return flakyStore(...a);
+ };
+ // Sync *passes*, not store calls: one pass writes a body per playlist plus
+ // the manifest, so counting writes says nothing about how many times the
+ // push was attempted — which is the whole question for "does it loop".
+ // Every pass begins with exactly one listing.
+ let passes = 0;
+ const flakyList = flaky.listUserBlobs.bind(flaky);
+ flaky.listUserBlobs = async (...a) => { passes++; return flakyList(...a); };
+ P.setPlaylistTransport(async () => flaky);
+
+ await P.addTracks(USER, p1, [track(50)], 'g1');
+ await new Promise((r) => setTimeout(r, 250));
+ const afterFirst = { stored: flaky.stored.length, last: P.lastSync() };
+ refuse = false; // the node comes back
+ await new Promise((r) => setTimeout(r, 900));
+ steps.push({ step: 'a failed push is retried once',
+ afterFirstTry: afterFirst.stored,
+ firstReason: afterFirst.last.reason,
+ afterRetry: flaky.stored.length,
+ ok: P.lastSync().ok });
+
+ // ── and only once ─────────────────────────────────────────────────────
+ refuse = true;
+ flaky.stored.length = 0;
+ passes = 0;
+ await P.addTracks(USER, p1, [track(51)], 'g1');
+ await new Promise((r) => setTimeout(r, 2000)); // room for four retries
+ steps.push({ step: 'a retry does not loop',
+ stored: flaky.stored.length, passes });
+
+ // ── closing the page sends what is pending ────────────────────────────
+ refuse = false;
+ P.setPushTimings({ debounceMs: 60000, retryMs: 60000 }); // never on its own
+ flaky.stored.length = 0;
+ await P.addTracks(USER, p1, [track(52)], 'g1');
+ const beforeFlush = flaky.stored.length;
+ await P.flushPush();
+ steps.push({ step: 'flush sends a pending push',
+ beforeFlush, afterFlush: flaky.stored.length });
+ P.setPushTimings({ debounceMs: 100, retryMs: 400 });
+
+ // ── a fresh device finds them ─────────────────────────────────────────
+ //
+ // What the phone did not do. `auto` now holds this account's playlists; a
+ // browser that has never seen them must end up with them.
+ P.setPlaylistTransport(async () => auto);
+ const before = (await P.listPlaylists(USER)).length;
+ await P.forgetLocal(USER);
+ const emptied = (await P.listPlaylists(USER)).length;
+ const pulled = await P.pullOnce(USER);
+ steps.push({ step: 'a fresh device pulls once',
+ before, emptied, result: pulled,
+ lists: (await P.listPlaylists(USER)).map((p) => p.name).sort() });
+
+ // A device that already holds playlists must pull too. Bounding this to an
+ // empty device left out the ordinary case — nine playlists here, a tenth
+ // made somewhere else — which would then have waited for a Music tab.
+ const elsewhere = fakeNode();
+ const mani = { v: 1, rev: 50, playlists: {
+ 'made-elsewhere': { name: 'Faite ailleurs', rev: 9, body_rev: 1, count: 1,
+ device: 'aaa', updated_at: 1, deleted: false } } };
+ elsewhere.rows.set(MANIFEST_KIND, {
+ rev: 50, blob: await seal(mani, MANIFEST_KIND, USER, key) });
+ elsewhere.rows.set(bodyKind('made-elsewhere'), { rev: 1, blob: await seal(
+ { v: 1, id: 'made-elsewhere', rev: 1, device: 'aaa',
+ tracks: [{ id: 'z1', g: 'g9', hv: 1, n: 'z.flac', s: 1, p: 'P',
+ t: 'Ailleurs', a: 'A', b: 'B', d: 1, tn: 1 }] },
+ bodyKind('made-elsewhere'), USER, key) });
+ P.setPlaylistTransport(async () => elsewhere);
+ const held = (await P.listPlaylists(USER)).length;
+ const pulled2 = await P.pullOnce(USER);
+ steps.push({ step: 'a device that already has playlists pulls too',
+ held, result: pulled2,
+ found: (await P.listPlaylists(USER)).map((p) => p.name).sort() });
+ P.setPlaylistTransport(async () => auto);
+
+ // ── a node holding something this browser cannot read ─────────────────
+ //
+ // The defect that made the phone report land: `open()` throwing on the
+ // node's manifest was treated as a fetch failure and returned before the
+ // push. The first write landed because the node was empty; every sync
+ // after it took that path and pushed nothing, for ever. Unreadable is an
+ // absence, not an error — the client is the authority.
+ const junkKey = await crypto.subtle.importKey(
+ 'raw', new Uint8Array(32).fill(200), { name: 'AES-GCM' }, false,
+ ['encrypt', 'decrypt']);
+ const wedged = fakeNode();
+ wedged.rows.set(MANIFEST_KIND, {
+ rev: 1,
+ blob: await seal({ v: 1, rev: 1, playlists: {} },
+ MANIFEST_KIND, USER, junkKey) });
+ const wedgedResult = await P.syncWith(wedged, USER);
+ let readBack = null;
+ try {
+ readBack = await open(wedged.rows.get(MANIFEST_KIND).blob,
+ MANIFEST_KIND, USER, key);
+ } catch { /* still unreadable means it was not overwritten */ }
+ steps.push({
+ step: 'an unreadable manifest does not wedge the sync',
+ result: wedgedResult,
+ overwritten: !!readBack,
+ names: readBack ? Object.values(readBack.playlists).map((p) => p.name).sort() : [],
+ });
+
// ── a session with no HKDF handle degrades rather than failing ─────────
+ P.setPlaylistTransport(null);
P.forgetPlaylistKey();
session.bundleKey = { v2: session.bundleKey.v2 }; // pre-change session
const r3 = await P.syncWith(fakeNode(), USER);
diff --git a/packages/meshbay-hub/tests/test_playlist_store.py b/packages/meshbay-hub/tests/test_playlist_store.py
index 203033c..59afaee 100644
--- a/packages/meshbay-hub/tests/test_playlist_store.py
+++ b/packages/meshbay-hub/tests/test_playlist_store.py
@@ -145,6 +145,133 @@ def test_a_deleted_playlists_body_is_reclaimed_from_the_node(steps):
assert steps["a deleted body is reclaimed"]["stillThere"] is False
+# ── every write leaves the browser ───────────────────────────────────────────
+#
+# The defect these exist for, reported from a phone: signing in with the same
+# account showed no playlists at all. `syncWith` was called from exactly one
+# place in the interface — after adding tracks from a cover — so creating a
+# playlist, deleting one, removing a track and saving the queue all wrote to
+# IndexedDB and stopped there.
+#
+# Nothing caught it because every test drove `syncWith` directly. The node's own
+# audit log did: two events, ever. The store pushes itself now, so a new
+# mutation cannot forget to.
+
+@pytest.mark.parametrize("step, expect_body", [
+ ("create pushes", True),
+ ("add pushes", True),
+ ("remove pushes", True),
+ ("save-queue pushes", True),
+ ("delete pushes", False),
+])
+def test_every_mutation_reaches_the_node(steps, step, expect_body):
+ kinds = steps[step]["kinds"]
+ assert "playlists" in kinds, f"{step}: the manifest never left the browser"
+ bodies = [k for k in kinds if k.startswith("playlist:")]
+ if expect_body:
+ assert bodies, f"{step}: no playlist body was pushed"
+ else:
+ assert not bodies, f"{step}: a deletion pushed a body"
+
+
+def test_deleting_a_playlist_takes_its_body_off_the_node(steps):
+ assert steps["delete pushes"]["bodyGone"] is True
+
+
+def test_a_burst_of_writes_is_one_push(steps):
+ """Five stars in a row is one manifest and one body, not five of each. The
+ push rides a connection borrowed from something else and must not spend it
+ once per keystroke."""
+ assert steps["a burst is coalesced"]["writes"] == 2
+
+
+def test_a_device_that_has_nothing_fetches_once(steps):
+ """The report, exactly: a phone signing in with the same account.
+
+ §7 said "on sign-in, nothing", on the grounds that the local copy is
+ authoritative and complete — true of a device that has been used before and
+ false of a new one, which is the case the whole feature exists for. Nothing
+ went looking until a group's Music tab happened to be opened.
+ """
+ s = steps["a fresh device pulls once"]
+ assert s["before"] > 0 and s["emptied"] == 0, (
+ "the fixture did not actually become a fresh device")
+ assert s["result"]["ok"] is True
+ assert s["result"]["pulled"] > 0
+ assert s["lists"], "a fresh device found no playlists — this is the bug"
+ assert "Depuis le menu" in s["lists"]
+
+
+# ── a push that does not land the first time ─────────────────────────────────
+
+def test_a_failed_push_is_retried(steps):
+ """A node that is busy, or a connection that drops between the write and
+ the push, used to mean waiting for the next Music tab to be opened. One
+ retry catches the common case: the node came back."""
+ s = steps["a failed push is retried once"]
+ assert s["afterFirstTry"] == 0, "the fixture's node did not actually refuse"
+ assert s["firstReason"], "the first failure recorded no reason"
+ assert s["afterRetry"] > 0, "the push was never retried"
+ assert s["ok"] is True
+
+
+def test_the_retry_does_not_loop(steps):
+ """Exactly one. A node that is off tends to stay off, and a push that keeps
+ looping spends a phone's battery on a node that is not coming back — while
+ the local copy is already correct and the next mutation carries it.
+
+ Counted as sync *passes*, not writes: one pass writes a body per playlist
+ plus the manifest, so counting writes would say nothing about how many
+ times the push was attempted.
+ """
+ assert steps["a retry does not loop"]["passes"] == 2, (
+ "one attempt plus one retry, and no more")
+
+
+def test_closing_the_page_sends_what_is_pending(steps):
+ """A push waits a second and a half to coalesce, and closing a laptop
+ inside that window is not rare. `pagehide` and a hidden tab flush it."""
+ s = steps["flush sends a pending push"]
+ assert s["beforeFlush"] == 0
+ assert s["afterFlush"] > 0
+
+
+def test_a_device_that_already_has_playlists_still_pulls_at_sign_in(steps):
+ """The correction to the correction.
+
+ Bounding the sign-in pull to an empty device left out the ordinary case: a
+ phone holding nine playlists and missing the tenth would not have gone
+ looking either, and would have waited for a group's Music tab to be opened
+ — which is not where anybody looks for a playlist.
+ """
+ s = steps["a device that already has playlists pulls too"]
+ assert s["held"] > 0, "the fixture device was empty, so this checks nothing"
+ assert s["result"]["ok"] is True
+ assert s["result"]["pulled"] > 0
+ assert "Faite ailleurs" in s["found"]
+
+
+def test_a_node_holding_something_unreadable_does_not_wedge_the_sync(steps):
+ """The defect behind the report from a phone.
+
+ `open()` throwing on the node's manifest was treated as a *fetch* failure
+ and returned before the push. The first write landed because the node was
+ empty, so nothing was fetched; every sync after it took that path and
+ pushed nothing, for ever, while the interface showed the playlists happily
+ from IndexedDB. The node's audit log was the only thing that said so.
+
+ A blob this account cannot open with this passphrase is not an older copy
+ of anything. It is an absence — the client is the authority (§6.4) — and it
+ gets overwritten.
+ """
+ s = steps["an unreadable manifest does not wedge the sync"]
+ assert s["result"]["ok"] is True
+ assert s["result"]["unreadable"] is True, "the failure was not even noticed"
+ assert s["result"]["pushed"] > 0, "the sync returned without pushing again"
+ assert s["overwritten"] is True, "the unreadable blob is still there"
+ assert "Depuis le menu" in s["names"]
+
+
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