diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:07:18 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:07:18 +0200 |
| commit | 2916aa376009283305a7acec4aafa3c96544499e (patch) | |
| tree | bc19d010e8bebf39c175271564b3be01d1c06aa0 /packages/meshbay-hub/tests/test_playlist_store.py | |
| parent | 25f62e169e049f0757ef611d5b409e7523958dee (diff) | |
| download | meshbay-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/test_playlist_store.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_playlist_store.py | 127 |
1 files changed, 127 insertions, 0 deletions
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 |