diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_resume_position.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_resume_position.py | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_resume_position.py b/packages/meshbay-hub/tests/test_resume_position.py new file mode 100644 index 0000000..ef239c5 --- /dev/null +++ b/packages/meshbay-hub/tests/test_resume_position.py @@ -0,0 +1,158 @@ +""" +"Resume where you left off" belongs to an account, not to a machine. + +The position was stored as `mb:pos:<file>` in localStorage — per *device*. Sign +in with a second account on the same computer and the player offered to resume a +film that account had never opened. Wrong on its own terms, and a small +disclosure of what the other person watches: the offer only appears for files +someone has actually been through. + +The functions are lifted out of `app.js` and run for real, rather than having +their source inspected, because the thing worth holding is the behaviour of two +accounts sharing one storage — which no assertion about the source text says. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +APP = STATIC / "app.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not APP.exists(), + reason="node or the SPA sources are not available") + +WANTED = ("resumeKey", "readResumePosition", "writeResumePosition", + "purgeUnscopedResumePositions") + + +def _extract() -> str: + """The real source of the functions under test, and nothing else. + + `app.js` imports preact and cannot be loaded outside a browser, so the + declarations are sliced out by brace matching. A rename breaks this loudly, + which is the intent — a silently skipped test is worse than a failing one. + """ + source = APP.read_text(encoding="utf-8") + out = [ + f"const RESUME_MIN_S = {_const(source, 'RESUME_MIN_S')};", + f"const RESUME_MAX_FRACTION = {_const(source, 'RESUME_MAX_FRACTION')};", + ] + for name in WANTED: + start = source.index(f"function {name}(") + depth, i = 0, source.index("{", start) + while True: + if source[i] == "{": + depth += 1 + elif source[i] == "}": + depth -= 1 + if depth == 0: + break + i += 1 + out.append(source[start:i + 1]) + return "\n".join(out) + + +def _const(source: str, name: str) -> str: + match = re.search(rf"^const {name} = ([^;]+);", source, re.M) + assert match, f"{name} is gone or was renamed" + return match.group(1) + + +def _run(body: str, tmp_path: Path): + script = tmp_path / "case.mjs" + script.write_text( + "const store = new Map();\n" + "globalThis.localStorage = {\n" + " get length() { return store.size; },\n" + " key: i => Array.from(store.keys())[i] ?? null,\n" + " getItem: k => (store.has(k) ? store.get(k) : null),\n" + " setItem: (k, v) => store.set(k, String(v)),\n" + " removeItem: k => store.delete(k),\n" + "};\n" + # Whoever is signed in, swapped by the cases below. + "let AUTH = null;\n" + "function loadAuth() { return AUTH; }\n" + f"{_extract()}\n" + "const out = [];\n" + "const say = (...a) => out.push(...a);\n" + "const keys = () => Array.from(store.keys()).sort();\n" + f"{body}\n" + "console.log(JSON.stringify(out));\n", + encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── The bug ───────────────────────────────────────────────────────────────── + +def test_a_second_account_is_not_offered_the_firsts_position(tmp_path): + """The report: a brand-new account was offered a resume point.""" + assert _run(""" +AUTH = { userId: 'alice' }; +writeResumePosition('film1', 600, 7200); +say(readResumePosition('film1')); + +AUTH = { userId: 'bob' }; +say(readResumePosition('film1')); +""", tmp_path) == [600, 0] + + +def test_each_account_keeps_its_own_place_in_the_same_film(tmp_path): + """Two people watching one film on one machine is the ordinary case, and + neither should move the other's bookmark.""" + assert _run(""" +AUTH = { userId: 'alice' }; writeResumePosition('film1', 600, 7200); +AUTH = { userId: 'bob' }; writeResumePosition('film1', 1800, 7200); +AUTH = { userId: 'alice' }; say(readResumePosition('film1')); +AUTH = { userId: 'bob' }; say(readResumePosition('film1')); +""", tmp_path) == [600, 1800] + + +def test_nothing_is_written_when_nobody_is_signed_in(tmp_path): + assert _run(""" +AUTH = null; +writeResumePosition('film1', 600, 7200); +say(keys().length, readResumePosition('film1')); +""", tmp_path) == [0, 0] + + +def test_positions_written_before_the_fix_are_dropped(tmp_path): + """ + They cannot be re-keyed: there is no record of whose they were, and guessing + hands them to whoever signs in next, which is the bug itself. + """ + assert _run(""" +localStorage.setItem('mb:pos:film1', '600'); // the old shape +localStorage.setItem('mb:pos:alice:film2', '900'); // the new one +localStorage.setItem('mb_auth', '{}'); // nothing to do with this +purgeUnscopedResumePositions(); +say(...keys()); +""", tmp_path) == ["mb:pos:alice:film2", "mb_auth"] + + +# ── What must still hold ──────────────────────────────────────────────────── + +def test_a_glance_at_the_opening_is_not_a_bookmark(tmp_path): + assert _run(""" +AUTH = { userId: 'alice' }; +writeResumePosition('film1', 12, 7200); +say(readResumePosition('film1')); +""", tmp_path) == [0] + + +def test_a_film_watched_to_the_end_stops_offering_to_resume(tmp_path): + """And the earlier bookmark goes with it, rather than sitting there + offering the last thirty seconds forever.""" + assert _run(""" +AUTH = { userId: 'alice' }; +writeResumePosition('film1', 3600, 7200); +writeResumePosition('film1', 7150, 7200); +say(readResumePosition('film1'), keys().length); +""", tmp_path) == [0, 0] |