diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
7 files changed, 183 insertions, 6 deletions
diff --git a/packages/meshbay-hub/tests/harness/photo_sync_probe.py b/packages/meshbay-hub/tests/harness/photo_sync_probe.py index 6c1a4d6..2a75749 100644 --- a/packages/meshbay-hub/tests/harness/photo_sync_probe.py +++ b/packages/meshbay-hub/tests/harness/photo_sync_probe.py @@ -106,6 +106,18 @@ window.meshbay = { permit: async () => ({}), configure: async () => ({}), plan: async () => ({ item: null }), sent: async () => true, completed: async () => ({}), failed: async () => true, }, + driveSync: { + status: async () => ({ + on: MODE === 'configured', unmetered: true, destination: dest, + dir: dest && '__FOLDER__/bob-drive', sent: 31, lastCompleted: Date.now(), failure: null, + folders: [{ id: 't1', name: 'Documents' }, + { id: 't2', name: 'Scans_from_the_office_printer_with_a_very_long_name_2026' }], + }), + addFolder: async () => { throw new Error('Refused: media_folder'); }, + removeFolder: async () => ({}), configure: async () => ({}), plan: async () => ({ items: [] }), + sent: async () => true, completed: async () => ({}), failed: async () => true, + keepAlive: async () => true, + }, messageSync: { status: async () => ({ permission: 'granted', on: MODE === 'configured', destination: dest, diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py index b5b0fa0..9275d67 100644 --- a/packages/meshbay-hub/tests/test_android_shell.py +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -136,7 +136,8 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each(): native = set() for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt", SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt", - SRC / "photos" / "PhotoChannels.kt", SRC / "phonesync" / "Destination.kt"): + SRC / "photos" / "PhotoChannels.kt", SRC / "phonesync" / "Destination.kt", + SRC / "drive" / "DriveChannels.kt"): native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M)) # Contacts and messages share one class, its channels named by a prefix. docs = set(re.findall(r'^\s*"(\w+)" ->', _read(SRC / "phonesync" / "DocChannels.kt"), flags=re.M)) @@ -148,7 +149,7 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each(): # address book to back up, and a desktop has none of them; those families are the ones the desktop lacks # rather than the ones it shares. phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "phonesync:", "contactsync:", - "calendarsync:", "messagesync:"))} + "calendarsync:", "messagesync:", "drivesync:"))} assert any(c.startswith("push:") for c in phone_only), "the push channels are gone" assert any(c.startswith("photosync:") for c in phone_only), "the backup channels are gone" assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}" @@ -267,9 +268,10 @@ def test_only_the_personal_profile_is_backed_up(): assert "Profiles.isPersonal(this)" in activity and "const BACKUP = $backups;" in activity shim = _strip_js_comments(_read(SHIM)) gated = shim.split("...(BACKUP ? {", 1)[1].split("} : {}),", 1)[0] - for name in ("photoSync:", "phoneSync:", "contactSync:", "calendarSync:"): + for name in ("photoSync:", "phoneSync:", "contactSync:", "calendarSync:", "driveSync:"): assert name in gated, name for path in [*(SRC / "phonesync").rglob("*.kt"), *(SRC / "photos").rglob("*.kt"), + *(SRC / "drive").rglob("*.kt"), *(APP / "src" / "full").rglob("*.kt")]: assert not re.search(r"\.ENTERPRISE_\w+", _read(path)), path.name 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}" diff --git a/packages/meshbay-hub/tests/test_first_load_is_lean.py b/packages/meshbay-hub/tests/test_first_load_is_lean.py index 84f5179..854d755 100644 --- a/packages/meshbay-hub/tests/test_first_load_is_lean.py +++ b/packages/meshbay-hub/tests/test_first_load_is_lean.py @@ -27,6 +27,7 @@ ON_DEMAND = { # The Android application's backups: no browser or desktop has them. "photo-sync.js", "photo-sync-settings.js", "android-sync-page.js", "phone-sync.js", "phone-sync-settings.js", "sync-destination.js", + "drive-sync.js", "drive-sync-settings.js", } diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index 6dddcc3..3a01e53 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -56,7 +56,7 @@ STATIC_FILES = [ "ask.js", # The Android application's backups, on their own page loaded on demand. "settings-page.js", "android-sync-page.js", "photo-sync-settings.js", - "phone-sync-settings.js", + "phone-sync-settings.js", "drive-sync-settings.js", ] pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py index 8cc9417..b3ab59e 100644 --- a/packages/meshbay-hub/tests/test_photo_sync_ui.py +++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py @@ -57,7 +57,7 @@ def test_limited_access_is_said_rather_than_failing_quietly(probe): def test_where_backups_go_lists_each_kinds_folder(probe): text = probe["dest360"]["text"] - assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "calendar", "messages")) + assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "calendar", "messages", "drive")) def test_photos_the_photos_tab_will_not_show_are_said(probe): @@ -79,3 +79,9 @@ def test_the_calendar_backup_says_where_its_copies_go(probe): def test_videos_are_offered_and_off_by_default(probe): assert "Videos too" in probe["setup360"]["text"] assert "Videos too" not in probe["setup360"]["checked"] + + +def test_the_files_backup_lists_its_folders_and_what_it_leaves_out(probe): + text = probe["configured360"]["text"] + assert "Documents" in text and "Scans_from_the_office_printer" in text + assert "DCIM, Pictures and Movies cannot be chosen" in text diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index bb39966..0f0edb8 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -295,7 +295,9 @@ def test_no_setter_survives_the_state_it_belonged_to(): # `useState` behind it by design. Without this the rule reports every # such helper, and a rule that cries wolf is one someone eventually # silences. - defined = set(re.findall(r"^(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M)) + # Nested too: a runner built by a factory (createBackup) keeps its + # own `setState` inside it. + defined = set(re.findall(r"^\s*(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M)) defined |= set(re.findall(r"^\s*const\s+(set[A-Z]\w*)\s*=", app, re.M)) # A setter named in a `function Component({ ..., setX, ... })` prop # list is handed down from wherever it is really declared. |