aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 15:50:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 15:50:39 +0200
commit1c97ffb91bb4928022e995709d7e55b19cfd18eb (patch)
treeaaa05d17adcd6db4256c01ba79b27dfc7ec96d70 /packages/meshbay-hub/tests
parent1521cc1ac0cca7ee8786056f84fa399d5cc268f9 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-hub/tests/harness/photo_sync_probe.py12
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py8
-rw-r--r--packages/meshbay-hub/tests/test_drive_sync.py154
-rw-r--r--packages/meshbay-hub/tests/test_first_load_is_lean.py1
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync_ui.py8
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py4
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.