diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 15:50:39 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 15:50:39 +0200 |
| commit | 1c97ffb91bb4928022e995709d7e55b19cfd18eb (patch) | |
| tree | aaa05d17adcd6db4256c01ba79b27dfc7ec96d70 /packages/meshbay-hub/tests/test_drive_sync.py | |
| parent | 1521cc1ac0cca7ee8786056f84fa399d5cc268f9 (diff) | |
| download | meshbay-1c97ffb91bb4928022e995709d7e55b19cfd18eb.tar.gz | |
feat(android): back up the files of folders the person chooses
A Files section takes folders through the system picker (no storage
permission), refuses DCIM, Pictures and Movies, skips what the photo backup
sends, and runs on the photo backup's own runner into <folder>/<account>-drive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_drive_sync.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_drive_sync.py | 154 |
1 files changed, 154 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_drive_sync.py b/packages/meshbay-hub/tests/test_drive_sync.py new file mode 100644 index 0000000..d46ac66 --- /dev/null +++ b/packages/meshbay-hub/tests/test_drive_sync.py @@ -0,0 +1,154 @@ +""" +The phone's files backup, as the page runs it (docs/MESHBAY_DESIGN.md §9.13). + +Run under Node against the real `drive-sync.js`, which is the photo backup's +own runner (`createBackup` in photo-sync.js) over the files bridge: the same +promises hold, once a day, on an unmetered network, each file under a slot +and read from the phone a range at a time, nothing removed from the node, +and only into a group the account owns alone. +""" + +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 / "drive-sync.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not SYNC.exists(), + reason="node or the SPA sources are not available") + +FAKE_POOL = r""" +export class ConnectionPool { + async connect(groupId) { + globalThis.fake.log.push(['connect', groupId]); + return { transport: globalThis.fake.transport, ack: {} }; + } + closeAll() { globalThis.fake.log.push(['close']); } +} +""" + +PRELUDE = r""" +const fake = globalThis.fake = { log: [], uploadError: null, members: [{ user_id: 'u-bob' }] }; +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, opts) => { + const range = opts && opts.headers && opts.headers.Range; + fake.log.push(['fetch', url, range || null]); + const m = /bytes=(\d+)-(\d+)/.exec(range || ''); + return { ok: true, status: m ? 206 : 200, + arrayBuffer: async () => new ArrayBuffer(m ? Number(m[2]) - Number(m[1]) + 1 : 0) }; +}; +fake.status = { + on: true, unmetered: true, sent: 0, now: Date.now(), + destination: { account: 'bob', groupId: 'g1', groupName: 'Mine', folder: 'Backups' }, + dir: 'Backups/bob-drive', folders: [{ id: 'tree1', name: 'Documents' }], + lastCompleted: null, failure: null, failureAt: null, +}; +fake.items = [ + { token: 'd1', name: 'notes.txt', dir: 'Backups/bob-drive/Documents', size: 6, edited: false }, + { token: 'd2', name: 'tax.pdf', dir: 'Backups/bob-drive/Documents/Tax/2026', size: 12, edited: false }, +]; +fake.index = { roots: [{ name: 'Backups', writable: true, available: true }], dirs: [], entries: [] }; +fake.transport = { + async fetchIndex() { return fake.index; }, + async createDirectory(at, name) { fake.log.push(['mkdir', `${at}/${name}`]); return {}; }, + openTransfer({ 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]); + if (fake.uploadError) { const e = new Error('refused'); e.code = fake.uploadError; throw e; } + await file.slice(0, file.size).arrayBuffer(); + return { stored_as: file.name, dir: opts.dir }; + }, +}; +const hubFetch = async () => ({ admin_id: 'u-bob', members: fake.members, invited: [] }); +win.meshbay = { driveSync: { + 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, text) => { fake.log.push(['failed', code, text]); 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 / 'drive-sync.js').as_uri()}');\n" + + "const ctx = { hub: 'https://hub.invalid', hubFetch, getUser: () => ({ username: 'bob', " + "userId: 'u-bob', token: 'tok' }), getBundleKey: async () => 'bk' };\n" + + body + + "\nm.stopDriveSync();\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.startDriveSync(ctx);\nawait m.runDriveSync();\n" + + +def _of(log, kind): + return [e[1:] for e in log if e[0] == kind] + + +def test_a_due_run_sends_each_file_into_its_folder_a_range_at_a_time(tmp_path): + log = _run(_start(), tmp_path) + assert _of(log, "mkdir") == [["Backups/bob-drive"], ["Backups/bob-drive/Documents"], + ["Backups/bob-drive/Documents/Tax"], + ["Backups/bob-drive/Documents/Tax/2026"]] + assert _of(log, "fetch") == [["/drivesync/d1", "bytes=0-5"], ["/drivesync/d2", "bytes=0-11"]] + assert _of(log, "sent") == [["d1", "Backups/bob-drive/Documents", "notes.txt"], + ["d2", "Backups/bob-drive/Documents/Tax/2026", "tax.pdf"]] + assert ["completed"] in log + + +def test_on_mobile_data_a_scheduled_run_waits(tmp_path): + log = _run(_start("fake.status.unmetered = false;"), tmp_path) + assert ["plan"] not in log + + +def test_no_folder_chosen_means_nothing_runs(tmp_path): + log = _run(_start("fake.status.folders = [];"), tmp_path) + assert ["plan"] not in log and _of(log, "connect") == [] + + +def test_a_group_with_somebody_else_in_it_stops_it_before_anything_is_read(tmp_path): + log = _run(_start("fake.members.push({ user_id: 'u-carol' });"), tmp_path) + assert [e[0] for e in _of(log, "failed")] == ["not_private"] + assert ["plan"] not in log + + +def test_a_lasting_refusal_names_the_files_backup(tmp_path): + log = _run(_start("fake.uploadError = 'disk_full';"), tmp_path) + failed = _of(log, "failed") + assert [e[0] for e in failed] == ["disk_full"] and failed[0][1] + assert ["completed"] not in log + + +def test_the_backup_has_no_way_to_remove_anything_on_the_node(): + for path in (SYNC, STATIC / "photo-sync.js"): + src = path.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"{path.name} reaches {op}" |