""" The phone's photo backup, as the page runs it (docs/MESHBAY_DESIGN.md §9.12). Run under Node against the real `photo-sync.js`, with the two things it talks to replaced: the Android bridge (`window.meshbay.photoSync`, which lists the photos and keeps the ledger) and the group connection (`connection-pool.js`, swapped in the copy for one that hands out a scripted node). What is pinned is what the person was promised: once a day, on an unmetered network, newest first, nothing ever deleted on the node, and a refusal that will hold tomorrow said once rather than retried at every opening. """ import json import re import shutil import subprocess from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" SYNC = STATIC / "photo-sync.js" pytestmark = pytest.mark.skipif( shutil.which("node") is None or not SYNC.exists(), reason="node or the SPA sources are not available") DAY = 24 * 3600 * 1000 FAKE_POOL = r""" export class ConnectionPool { constructor(hub) { globalThis.fake.pools.push(hub); } async connect(groupId) { if (globalThis.fake.refuseConnect) { const e = new Error('refused'); e.reason = globalThis.fake.refuseConnect; throw e; } globalThis.fake.log.push(['connect', groupId]); return { transport: globalThis.fake.transport, ack: globalThis.fake.ack }; } closeAll() { globalThis.fake.log.push(['close']); } } """ PRELUDE = r""" const fake = globalThis.fake = { log: [], pools: [], refuseConnect: null, uploadError: null, afterUpload: null }; const win = new EventTarget(); const doc = new EventTarget(); doc.visibilityState = 'visible'; globalThis.window = win; globalThis.document = doc; globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} }; globalThis.fetch = async (url) => { fake.log.push(['fetch', url]); return { ok: true, blob: async () => new Blob([new Uint8Array(4)]) }; }; const NOW = Date.now(); fake.status = { permission: 'granted', unmetered: true, sent: 0, now: NOW, config: { account: 'bob', groupId: 'g1', groupName: 'Family', owner: 'alice', folder: 'Media/Photos/Bob', albums: ['cam'], includeExisting: true, since: 0 }, lastCompleted: null, failure: null, failureAt: null, }; fake.items = [ { token: 't1', name: 'IMG_3.jpg', dir: 'Media/Photos/Bob/2026/10', size: 30, edited: false, alsoKnownAs: 'IMG_3-edited-x.jpg' }, { token: 't2', name: 'IMG_2.jpg', dir: 'Media/Photos/Bob/2026/09', size: 20, edited: false, alsoKnownAs: 'IMG_2-edited-x.jpg' }, { token: 't3', name: 'IMG_1.jpg', dir: 'Media/Photos/Bob/2026/09', size: 10, edited: false, alsoKnownAs: 'IMG_1-edited-x.jpg' }, ]; fake.index = { roots: [{ name: 'Media', writable: true, available: true }], dirs: ['Media/Photos', 'Media/Photos/Bob', 'Media/Photos/Bob/2026', 'Media/Photos/Bob/2026/09'], entries: [{ path: 'Media/Photos/Bob/2026/09', name: 'IMG_1.jpg', size: 10 }], }; fake.ack = { photo_directories: ['Media/Photos'] }; fake.transport = { connected: true, async fetchIndex() { return fake.index; }, async createDirectory(at, name) { fake.log.push(['mkdir', `${at}/${name}`]); return {}; }, openTransfer({ kind, bytes }) { fake.log.push(['lease', kind, bytes]); return { tr: 'tr-' + bytes, acquire: async () => {}, release: () => fake.log.push(['release', bytes]) }; }, async uploadFile(file, opts) { fake.log.push(['upload', opts.dir, file.name, opts.tr]); if (fake.uploadError) { const e = new Error('refused'); e.code = fake.uploadError; throw e; } if (fake.afterUpload) fake.afterUpload(); return { stored_as: file.name, dir: opts.dir }; }, }; win.meshbay = { photoSync: { status: async () => fake.status, plan: async () => { fake.log.push(['plan']); return { items: fake.items }; }, sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; }, completed: async () => { fake.log.push(['completed']); fake.status.lastCompleted = Date.now(); return fake.status; }, failed: async (code) => { fake.log.push(['failed', code]); return true; }, keepAlive: async (on) => { fake.log.push(['keep', on]); return true; }, } }; """ def _run(body, tmp_path): root = tmp_path / "static" shutil.copytree(STATIC, root) (root / "package.json").write_text('{"type":"module"}', encoding="utf-8") (root / "connection-pool.js").write_text(FAKE_POOL, encoding="utf-8") script = tmp_path / "case.mjs" script.write_text( PRELUDE + f"const m = await import('{(root / 'photo-sync.js').as_uri()}');\n" + "const ctx = { hub: 'https://hub.invalid', getUser: () => ({ username: 'bob', " "userId: 'u-bob', token: 'tok' }), getBundleKey: async () => 'bk' };\n" + body # The next run's timer would keep Node alive for a day. + "\nm.stopPhotoSync();\nconsole.log(JSON.stringify(fake.log));\n", encoding="utf-8") proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8", timeout=30) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout.strip().splitlines()[-1]) def _start(extra=""): return extra + "\nm.startPhotoSync(ctx);\nawait m.runPhotoSync();\n" def _of(log, kind): return [e[1:] for e in log if e[0] == kind] # ── a run ─────────────────────────────────────────────────────────────────── def test_a_due_run_sends_newest_first_and_records_each(tmp_path): log = _run(_start(), tmp_path) # IMG_1 is already in the folder (a reinstall's empty ledger): recorded, # not sent again. assert _of(log, "upload") == [ ["Media/Photos/Bob/2026/10", "IMG_3.jpg", "tr-30"], ["Media/Photos/Bob/2026/09", "IMG_2.jpg", "tr-20"], ] assert _of(log, "sent") == [ ["t1", "Media/Photos/Bob/2026/10", "IMG_3.jpg"], ["t2", "Media/Photos/Bob/2026/09", "IMG_2.jpg"], ["t3", "Media/Photos/Bob/2026/09", "IMG_1.jpg"], ] assert ["completed"] in log assert _of(log, "keep")[0] == [True] and _of(log, "keep")[-1] == [False], ( "the screen-off keep-alive was not released after the run") def test_only_missing_folders_are_made_and_parents_first(tmp_path): log = _run(_start(), tmp_path) assert _of(log, "mkdir") == [["Media/Photos/Bob/2026/10"]] def test_every_upload_holds_a_slot_and_gives_it_back(tmp_path): """The backup queues like any member: one slot, returned every time.""" log = _run(_start(), tmp_path) assert _of(log, "lease") == [["upload", 30], ["upload", 20]] assert _of(log, "release") == [[30], [20]] def test_an_edit_under_its_own_name_counts_as_already_there(tmp_path): log = _run(_start(""" fake.index.entries.push({ path: 'Media/Photos/Bob/2026/10', name: 'IMG_3-edited-x.jpg', size: 30 }); """), tmp_path) assert ["t1", "Media/Photos/Bob/2026/10", "IMG_3-edited-x.jpg"] in _of(log, "sent") assert all(u[1] != "IMG_3.jpg" for u in _of(log, "upload")) # ── when ──────────────────────────────────────────────────────────────────── def test_nothing_happens_before_a_day_has_passed(tmp_path): log = _run(_start("fake.status.lastCompleted = Date.now() - 3600 * 1000;"), tmp_path) assert ["plan"] not in log and _of(log, "connect") == [] def test_a_day_later_it_runs(tmp_path): log = _run(_start(f"fake.status.lastCompleted = Date.now() - {DAY} - 1000;"), tmp_path) assert ["completed"] in log def test_back_up_now_runs_whenever(tmp_path): log = _run(""" fake.status.lastCompleted = Date.now() - 60000; m.startPhotoSync(ctx); await m.runPhotoSync(); fake.log.length = 0; await m.runPhotoSync({ force: true }); """, tmp_path) assert ["completed"] in log def test_on_a_metered_network_a_scheduled_run_waits(tmp_path): log = _run(_start("fake.status.unmetered = false;"), tmp_path) assert ["plan"] not in log def test_a_run_the_person_allowed_on_mobile_data_goes(tmp_path): log = _run(""" fake.status.unmetered = false; m.startPhotoSync(ctx); await m.runPhotoSync(); fake.log.length = 0; await m.runPhotoSync({ force: true, metered: true }); """, tmp_path) assert ["completed"] in log def test_leaving_wifi_stops_after_the_file_in_flight(tmp_path): log = _run(_start(""" fake.afterUpload = () => { fake.afterUpload = null; window.dispatchEvent(new CustomEvent('meshbay-network', { detail: { unmetered: false } })); }; """), tmp_path) assert len(_of(log, "upload")) == 1, "it went on sending over mobile data" assert ["completed"] not in log, "an unfinished run was recorded as the day's run" def test_wifi_coming_back_starts_a_waiting_run(tmp_path): log = _run(""" fake.status.unmetered = false; m.startPhotoSync(ctx); await m.runPhotoSync(); fake.status.unmetered = true; window.dispatchEvent(new CustomEvent('meshbay-network', { detail: { unmetered: true } })); await m.runPhotoSync(); """, tmp_path) assert ["completed"] in log # ── refusals ──────────────────────────────────────────────────────────────── @pytest.mark.parametrize("code", ["disk_full", "root_read_only", "no_such_directory", "root_unavailable"]) def test_a_lasting_refusal_stops_and_is_said_once(tmp_path, code): log = _run(_start(f"fake.uploadError = '{code}';"), tmp_path) assert _of(log, "failed") == [[code]] assert len(_of(log, "upload")) == 1, "it carried on into the same refusal" assert ["completed"] not in log def test_after_a_lasting_refusal_it_waits_a_day(tmp_path): log = _run(_start(""" fake.status.failure = 'disk_full'; fake.status.failureAt = Date.now() - 3600 * 1000; """), tmp_path) assert ["plan"] not in log def test_a_member_removed_from_the_group_is_told_once(tmp_path): log = _run(_start("fake.refuseConnect = 'not_a_member';"), tmp_path) assert _of(log, "failed") == [["not_a_member"]] def test_an_offline_node_is_not_a_lasting_refusal(tmp_path): log = _run(_start("fake.refuseConnect = 'timeout';"), tmp_path) assert _of(log, "failed") == [] and ["completed"] not in log def test_a_folder_no_longer_writable_is_found_before_sending(tmp_path): log = _run(_start("fake.index.roots[0].writable = false;"), tmp_path) assert _of(log, "failed") == [["root_read_only"]] assert _of(log, "upload") == [] def test_another_account_on_the_phone_sends_nothing(tmp_path): log = _run(_start("fake.status.config.account = 'carol';"), tmp_path) assert ["plan"] not in log # ── choosing a folder ─────────────────────────────────────────────────────── def test_only_writable_folders_the_photos_tab_shows_are_offered(tmp_path): log = _run(""" fake.log.push(['folders', m.backupFolders( { photo_directories: ['Media/Photos', 'Archive/Pictures'] }, { roots: [{ name: 'Media', writable: true, available: true }, { name: 'Archive', writable: false, available: true }, { name: 'Ejected', writable: true, available: false }], dirs: ['Media/Films', 'Media/Photos', 'Media/Photos/Bob', 'Archive/Pictures', 'Ejected/Photos'] })]); """, tmp_path) assert _of(log, "folders") == [[["Media/Photos", "Media/Photos/Bob"]]] # ── nothing is ever removed ───────────────────────────────────────────────── def test_the_backup_has_no_way_to_remove_anything_on_the_node(): """ Additive by construction: a photo deleted on the phone stays in the group, and nothing the sync does can take one away. Read from the source, because the property is the absence of a call. """ src = SYNC.read_text(encoding="utf-8") for op in ("deleteFile", "deleteDirectory", "removeRoot", "file_delete", "dir_delete", "root_remove", "rename"): assert not re.search(rf"\b{op}\b", src), f"photo-sync.js reaches {op}"