aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_phone_sync.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 12:56:26 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 12:56:26 +0200
commita0793dd37030f7213ecb07a018b11e6a8adf9238 (patch)
tree5de1046eae9b3747ac97a81652590fb6af99622b /packages/meshbay-hub/tests/test_phone_sync.py
parent044c585f73cfd548e300859e89eaf5dcb40d3d3c (diff)
downloadmeshbay-a0793dd37030f7213ecb07a018b11e6a8adf9238.tar.gz
feat(android): back the phone's contacts up to a group of one's own
A Contacts backup section on the Android Sync page sends a dated .vcf into <folder>/<account>-contacts once a day when the address book changed, only to a group the account is alone in, checked again at every run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_phone_sync.py')
-rw-r--r--packages/meshbay-hub/tests/test_phone_sync.py266
1 files changed, 266 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_phone_sync.py b/packages/meshbay-hub/tests/test_phone_sync.py
new file mode 100644
index 0000000..7e5a88e
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_phone_sync.py
@@ -0,0 +1,266 @@
+"""
+The phone's contacts backup, as the page runs it (docs/MESHBAY_DESIGN.md §9.13).
+
+Run under Node against the real `phone-sync.js`, with what it talks to
+replaced: the Android bridge (`window.meshbay.contactSync`, which writes the
+file and remembers what the node took), the hub's member list, and the group
+connection (`connection-pool.js`, swapped in the copy). What is pinned is what
+the person was promised: once a day, one dated file into
+`<folder>/<account>-contacts`, only while they are alone in the group, nothing
+ever deleted on the node, and a lasting refusal said once.
+"""
+
+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 / "phone-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: {} };
+ }
+ closeAll() { globalThis.fake.log.push(['close']); }
+}
+"""
+
+PRELUDE = r"""
+const fake = globalThis.fake = { log: [], pools: [], refuseConnect: null, uploadError: null,
+ members: [{ username: 'bob' }], invited: [], membersError: 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)]) };
+};
+
+fake.status = {
+ permission: 'granted', sent: 0, now: Date.now(),
+ config: { account: 'bob', groupId: 'g1', groupName: 'Mine', owner: 'bob', folder: 'Backups' },
+ dir: 'Backups/bob-contacts',
+ lastCompleted: null, lastSent: null, failure: null, failureAt: null,
+};
+fake.item = { token: 't1', name: 'contacts-2026-10-10-0900.vcf', dir: 'Backups/bob-contacts',
+ size: 4, count: 12 };
+fake.index = {
+ roots: [{ name: 'Backups', writable: true, available: true }],
+ dirs: [],
+ entries: [],
+};
+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; }
+ return { stored_as: file.name, dir: opts.dir };
+ },
+};
+const hubFetch = async (path) => {
+ fake.log.push(['hub', path]);
+ if (fake.membersError) throw new Error(fake.membersError);
+ return { members: fake.members, invited: fake.invited };
+};
+win.meshbay = { contactSync: {
+ status: async () => fake.status,
+ plan: async () => { fake.log.push(['plan']); return { item: fake.item }; },
+ 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; },
+} };
+"""
+
+
+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 / 'phone-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
+ # The next run's timer would keep Node alive for a day.
+ + "\nm.stopPhoneSync();\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.startPhoneSync(ctx);\nawait m.runDocSync('contacts');\n"
+
+
+def _of(log, kind):
+ return [e[1:] for e in log if e[0] == kind]
+
+
+# ── a run ───────────────────────────────────────────────────────────────────
+
+def test_a_due_run_sends_one_dated_file_into_the_account_folder(tmp_path):
+ log = _run(_start(), tmp_path)
+ assert _of(log, "mkdir") == [["Backups/bob-contacts"]]
+ assert _of(log, "upload") == [["Backups/bob-contacts", "contacts-2026-10-10-0900.vcf", "tr-4"]]
+ assert _of(log, "fetch") == [["/phonesync/t1"]]
+ assert _of(log, "sent") == [["t1", "Backups/bob-contacts", "contacts-2026-10-10-0900.vcf"]]
+ assert ["completed"] in log
+ assert _of(log, "release") == [[4]]
+
+
+def test_a_folder_already_there_is_not_made_again(tmp_path):
+ log = _run(_start("fake.index.dirs = ['Backups/bob-contacts'];"), tmp_path)
+ assert _of(log, "mkdir") == []
+ assert len(_of(log, "upload")) == 1
+
+
+def test_nothing_new_finishes_the_run_without_connecting(tmp_path):
+ log = _run(_start("fake.item = null;"), tmp_path)
+ assert ["completed"] in log
+ assert _of(log, "connect") == []
+
+
+def test_nothing_happens_before_a_day_has_passed(tmp_path):
+ log = _run(_start("fake.status.lastCompleted = Date.now() - 3600 * 1000;"), tmp_path)
+ assert _of(log, "plan") == [] and _of(log, "hub") == []
+
+
+def test_a_day_later_it_runs(tmp_path):
+ log = _run(_start(f"fake.status.lastCompleted = Date.now() - {DAY + 1000};"), tmp_path)
+ assert len(_of(log, "upload")) == 1
+
+
+def test_back_up_now_runs_whenever(tmp_path):
+ log = _run("""
+fake.status.lastCompleted = Date.now() - 1000;
+m.startPhoneSync(ctx);
+await m.runDocSync('contacts');
+await m.runDocSync('contacts', { force: true });
+""", tmp_path)
+ assert len(_of(log, "upload")) == 1
+
+
+# ── alone in the group, at every run ────────────────────────────────────────
+
+def test_the_group_is_asked_about_at_every_run(tmp_path):
+ log = _run(_start(), tmp_path)
+ assert _of(log, "hub") == [["/v1/groups/g1/members"]]
+
+
+@pytest.mark.parametrize("setup", [
+ "fake.members = [{ username: 'bob' }, { username: 'carol' }];",
+ "fake.invited = [{ username: 'carol' }];",
+])
+def test_somebody_else_in_the_group_stops_it_before_anything_is_read(tmp_path, setup):
+ log = _run(_start(setup), tmp_path)
+ assert [e[0] for e in _of(log, "failed")] == ["not_private"]
+ assert _of(log, "plan") == [] and _of(log, "upload") == []
+
+
+def test_a_member_removed_from_the_group_is_told_once(tmp_path):
+ log = _run(_start("fake.membersError = 'Not a member';"), tmp_path)
+ assert [e[0] for e in _of(log, "failed")] == ["not_a_member"]
+
+
+def test_an_unreachable_hub_is_not_a_lasting_refusal(tmp_path):
+ log = _run(_start("fake.membersError = 'Failed to fetch';"), tmp_path)
+ assert _of(log, "failed") == [] and _of(log, "plan") == []
+
+
+def test_only_groups_this_account_is_alone_in_are_offered(tmp_path):
+ log = _run("""
+m.startPhoneSync(ctx);
+const real = ctx.hubFetch;
+ctx.hubFetch = async (path) => path.includes('/g2/')
+ ? { members: [{ username: 'bob' }, { username: 'carol' }] }
+ : path.includes('/g3/') ? Promise.reject(new Error('offline')) : real(path);
+fake.log.push(['offered', (await m.privateGroups(
+ [{ id: 'g1', name: 'Mine' }, { id: 'g2', name: 'Family' }, { id: 'g3', name: 'Far' }]))
+ .map((g) => g.id)]);
+""", tmp_path)
+ assert _of(log, "offered") == [[["g1"]]]
+
+
+# ── refusals ────────────────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("code", ["disk_full", "root_read_only"])
+def test_a_lasting_refusal_stops_and_is_said(tmp_path, code):
+ log = _run(_start(f"fake.uploadError = '{code}';"), tmp_path)
+ failed = _of(log, "failed")
+ assert [e[0] for e in failed] == [code]
+ assert failed[0][1], "the phone is given nothing to say"
+ assert ["completed"] not in log
+
+
+def test_a_folder_gone_is_found_before_sending(tmp_path):
+ log = _run(_start("fake.index.roots = [];"), tmp_path)
+ assert [e[0] for e in _of(log, "failed")] == ["no_such_root"]
+ assert _of(log, "upload") == []
+
+
+def test_without_the_permission_it_stops_and_says_so(tmp_path):
+ log = _run(_start("fake.status.permission = 'denied';"), tmp_path)
+ assert [e[0] for e in _of(log, "failed")] == ["permission"]
+
+
+def test_another_account_on_the_phone_sends_nothing(tmp_path):
+ log = _run(_start("fake.status.config.account = 'alice';"), tmp_path)
+ assert _of(log, "plan") == [] and _of(log, "hub") == []
+
+
+# ── folders offered ─────────────────────────────────────────────────────────
+
+def test_every_writable_folder_is_offered_not_only_photo_ones(tmp_path):
+ log = _run("""
+fake.log.push(['folders', m.writableFolders(
+ { roots: [{ name: 'Media', writable: true, available: true },
+ { name: 'Archive', writable: false, available: true },
+ { name: 'Ejected', writable: true, available: false }],
+ dirs: ['Media/Films', 'Media/Photos', 'Archive/Old', 'Ejected/Stuff'] })]);
+""", tmp_path)
+ assert _of(log, "folders") == [[["Media", "Media/Films", "Media/Photos"]]]
+
+
+# ── nothing is ever removed ─────────────────────────────────────────────────
+
+def test_the_backup_has_no_way_to_remove_anything_on_the_node():
+ """Every copy is a new file; 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"phone-sync.js reaches {op}"