diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 14:21:46 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 14:21:46 +0200 |
| commit | 2f2a9a6542d2194e50ffba6f382e4fdffd481838 (patch) | |
| tree | 5437330f9e8b963f8af0904f3c5d80d1afc6dc3b /packages/meshbay-hub/tests | |
| parent | 914d2f0af848fb80d70d517901f44420a4e47764 (diff) | |
| download | meshbay-2f2a9a6542d2194e50ffba6f382e4fdffd481838.tar.gz | |
feat(android): one backup destination, a group the account owns alone
Chosen once at the top of Android Sync for every kind; photos and contacts
go into <folder>/<account>-photos and -contacts. Owner and sole member are
checked at set-up and before every run.
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.py | 40 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_android_shell.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_first_load_is_lean.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_phone_sync.py | 45 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_photo_sync.py | 50 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_photo_sync_ui.py | 13 |
6 files changed, 110 insertions, 44 deletions
diff --git a/packages/meshbay-hub/tests/harness/photo_sync_probe.py b/packages/meshbay-hub/tests/harness/photo_sync_probe.py index 08c21b9..c1c4599 100644 --- a/packages/meshbay-hub/tests/harness/photo_sync_probe.py +++ b/packages/meshbay-hub/tests/harness/photo_sync_probe.py @@ -8,8 +8,9 @@ Only the Android application has this section, and its engine is Chromium, so one engine is the whole of it. The bridge is a stand-in that answers the way the phone does; the group connection (`connection-pool.js`) is replaced in the copy by one that hands out a node with long, space-free folder names, because -those are what widen a page (CLAUDE.md). Three states: setting up, set up with -a refusal showing, and setting up with only selected photos allowed. +those are what widen a page (CLAUDE.md). Four states: choosing where backups +go, setting up photos, set up with a refusal showing, and setting up with only +selected photos allowed. photo_sync_probe.py prints one JSON object, keyed <state><width> """ @@ -61,16 +62,20 @@ window.addEventListener('error', (e) => note(e.message)); window.addEventListener('unhandledrejection', (e) => note((e.reason && e.reason.message) || e.reason)); const MODE = location.hash.slice(1); -const cfg = { - account: 'bob', groupId: 'g1', groupName: '__GROUP__', owner: '__OWNER__', - folder: '__FOLDER__', albums: ['cam'], includeExisting: true, since: 0, -}; +const dest = MODE === 'dest' ? null + : { account: 'bob', groupId: 'g1', groupName: '__GROUP__', folder: '__FOLDER__' }; +const cfg = { albums: ['cam'], includeExisting: true, since: 0 }; window.meshbay = { hubBase: () => 'https://hub.invalid', + phoneSync: { + destination: async () => dest, + setDestination: async (d) => d, + }, photoSync: { status: async () => ({ permission: MODE === 'partial' ? 'partial' : 'granted', unmetered: true, sent: 1240, config: MODE === 'configured' ? cfg : null, lastCompleted: Date.now(), + destination: dest, dir: dest && '__FOLDER__/bob-photos', failure: MODE === 'configured' ? 'disk_full' : null, }), albums: async () => [ @@ -83,6 +88,15 @@ window.meshbay = { sent: async () => true, completed: async () => ({}), failed: async () => true, keepAlive: async () => true, }, + contactSync: { + status: async () => ({ + permission: 'granted', on: MODE === 'configured', destination: dest, + dir: dest && '__FOLDER__/bob-contacts', sent: 3, lastCompleted: Date.now(), + lastSent: dest && '__FOLDER__/bob-contacts/contacts-2026-10-10-0900.vcf', failure: null, + }), + permit: async () => ({}), configure: async () => ({}), plan: async () => ({ item: null }), + sent: async () => true, completed: async () => ({}), failed: async () => true, + }, }; </script> <main class="main"><div id=root></div></main> @@ -92,14 +106,18 @@ import { initLocale } from './i18n.js'; import { startPhotoSync } from './photo-sync.js'; import { AndroidSyncPage } from './android-sync-page.js'; await initLocale(); -startPhotoSync({ hub: '', getUser: () => ({ username: 'bob', token: 't' }), - getBundleKey: async () => '' }); +startPhotoSync({ + hub: '', getUser: () => ({ username: 'bob', userId: 'u-bob', token: 't' }), + hubFetch: async () => ({ admin_id: 'u-bob', members: [{ user_id: 'u-bob', username: 'bob' }] }), + getBundleKey: async () => '' }); const groups = [{ id: 'g1', name: '__GROUP__', owner_username: '__OWNER__' }]; -render(html`<${AndroidSyncPage} user=${{ username: 'bob', token: 't' }} groups=${groups} />`, +render(html`<${AndroidSyncPage} user=${{ username: 'bob', userId: 'u-bob', token: 't' }} + groups=${groups} />`, document.getElementById('root')); setTimeout(() => { if (MODE === 'configured') return; [...document.querySelectorAll('button')].find((b) => b.textContent.includes('Set up')).click(); + if (MODE !== 'dest') return; setTimeout(() => { const sel = document.querySelector('select'); sel.value = 'g1'; @@ -112,7 +130,7 @@ setTimeout(() => { OUTER = """<!doctype html><html><body style="margin:0"> <script> -for (const m of ['setup', 'configured', 'partial']) for (const w of [360, 420]) { +for (const m of ['dest', 'setup', 'configured', 'partial']) for (const w of [360, 420]) { const f = document.createElement('iframe'); f.id = m + w; f.style.cssText = `width:${w}px;height:900px;border:0;display:block`; @@ -129,7 +147,7 @@ setTimeout(() => { .map((e) => `${e.tagName}.${e.className} ${Math.round(e.getBoundingClientRect().right)}`); out[f.id] = { vw, scrollW: d.documentElement.scrollWidth, wide: wide.slice(0, 6), - text: d.querySelector('#root').innerText.slice(0, 600), + text: d.querySelector('#root').innerText.slice(0, 3000), err: d.documentElement.dataset.err || '', checked: [...d.querySelectorAll('#root input:checked')] .map((i) => i.closest('label').innerText.split('\\n')[0]), diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py index b92cfef..426b560 100644 --- a/packages/meshbay-hub/tests/test_android_shell.py +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -136,7 +136,7 @@ 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 / "photos" / "PhotoChannels.kt", SRC / "phonesync" / "Destination.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)) @@ -147,7 +147,7 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each(): # A phone has a push distributor to talk to and a camera roll and an # 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:", "contactsync:"))} + phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "phonesync:", "contactsync:"))} 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}" 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 2e24a6c..84f5179 100644 --- a/packages/meshbay-hub/tests/test_first_load_is_lean.py +++ b/packages/meshbay-hub/tests/test_first_load_is_lean.py @@ -26,7 +26,7 @@ ON_DEMAND = { "video-player.js", "group-settings.js", "search-page.js", # 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", + "phone-sync.js", "phone-sync-settings.js", "sync-destination.js", } diff --git a/packages/meshbay-hub/tests/test_phone_sync.py b/packages/meshbay-hub/tests/test_phone_sync.py index 7e5a88e..1a35614 100644 --- a/packages/meshbay-hub/tests/test_phone_sync.py +++ b/packages/meshbay-hub/tests/test_phone_sync.py @@ -43,7 +43,8 @@ export class ConnectionPool { PRELUDE = r""" const fake = globalThis.fake = { log: [], pools: [], refuseConnect: null, uploadError: null, - members: [{ username: 'bob' }], invited: [], membersError: null }; + members: [{ user_id: 'u-bob', username: 'bob' }], invited: [], + adminId: 'u-bob', membersError: null }; const win = new EventTarget(); const doc = new EventTarget(); doc.visibilityState = 'visible'; @@ -56,8 +57,8 @@ globalThis.fetch = async (url) => { }; fake.status = { - permission: 'granted', sent: 0, now: Date.now(), - config: { account: 'bob', groupId: 'g1', groupName: 'Mine', owner: 'bob', folder: 'Backups' }, + permission: 'granted', sent: 0, now: Date.now(), on: true, + destination: { account: 'bob', groupId: 'g1', groupName: 'Mine', folder: 'Backups' }, dir: 'Backups/bob-contacts', lastCompleted: null, lastSent: null, failure: null, failureAt: null, }; @@ -86,7 +87,7 @@ fake.transport = { const hubFetch = async (path) => { fake.log.push(['hub', path]); if (fake.membersError) throw new Error(fake.membersError); - return { members: fake.members, invited: fake.invited }; + return { admin_id: fake.adminId, members: fake.members, invited: fake.invited }; }; win.meshbay = { contactSync: { status: async () => fake.status, @@ -111,6 +112,7 @@ def _run(body, tmp_path): script.write_text( PRELUDE + f"const m = await import('{(root / 'phone-sync.js').as_uri()}');\n" + + f"const d = await import('{(root / 'sync-destination.js').as_uri()}');\n" + "const ctx = { hub: 'https://hub.invalid', hubFetch, getUser: () => ({ username: 'bob', " "userId: 'u-bob', token: 'tok' }), getBundleKey: async () => 'bk' };\n" + body @@ -183,8 +185,9 @@ def test_the_group_is_asked_about_at_every_run(tmp_path): @pytest.mark.parametrize("setup", [ - "fake.members = [{ username: 'bob' }, { username: 'carol' }];", - "fake.invited = [{ username: 'carol' }];", + "fake.members.push({ user_id: 'u-carol', username: 'carol' });", + "fake.invited = [{ user_id: 'u-carol', username: 'carol' }];", + "fake.adminId = 'u-carol';", ]) def test_somebody_else_in_the_group_stops_it_before_anything_is_read(tmp_path, setup): log = _run(_start(setup), tmp_path) @@ -202,15 +205,18 @@ def test_an_unreachable_hub_is_not_a_lasting_refusal(tmp_path): assert _of(log, "failed") == [] and _of(log, "plan") == [] -def test_only_groups_this_account_is_alone_in_are_offered(tmp_path): +def test_only_groups_this_account_owns_and_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' }])) + ? { admin_id: 'u-bob', members: [{ user_id: 'u-bob' }, { user_id: 'u-carol' }] } + : path.includes('/g3/') ? Promise.reject(new Error('offline')) + : path.includes('/g4/') ? { admin_id: 'u-carol', members: [{ user_id: 'u-bob' }] } + : real(path); +fake.log.push(['offered', (await d.privateGroups( + [{ id: 'g1', name: 'Mine' }, { id: 'g2', name: 'Family' }, { id: 'g3', name: 'Far' }, + { id: 'g4', name: 'Given' }])) .map((g) => g.id)]); """, tmp_path) assert _of(log, "offered") == [[["g1"]]] @@ -239,7 +245,7 @@ def test_without_the_permission_it_stops_and_says_so(tmp_path): def test_another_account_on_the_phone_sends_nothing(tmp_path): - log = _run(_start("fake.status.config.account = 'alice';"), tmp_path) + log = _run(_start("fake.status.destination.account = 'alice';"), tmp_path) assert _of(log, "plan") == [] and _of(log, "hub") == [] @@ -247,7 +253,7 @@ def test_another_account_on_the_phone_sends_nothing(tmp_path): def test_every_writable_folder_is_offered_not_only_photo_ones(tmp_path): log = _run(""" -fake.log.push(['folders', m.writableFolders( +fake.log.push(['folders', d.writableFolders( { roots: [{ name: 'Media', writable: true, available: true }, { name: 'Archive', writable: false, available: true }, { name: 'Ejected', writable: true, available: false }], @@ -256,6 +262,19 @@ fake.log.push(['folders', m.writableFolders( assert _of(log, "folders") == [[["Media", "Media/Films", "Media/Photos"]]] +def test_photos_outside_the_photos_tab_folders_are_said(tmp_path): + log = _run(""" +fake.log.push(['shows', ['Media/Photos/bob-photos', 'Media/Other/bob-photos', 'Media/PhotosX'] + .map((dir) => d.photosTabShows(dir, ['Media/Photos']))]); +""", tmp_path) + assert _of(log, "shows") == [[[True, False, False]]] + + +def test_turned_off_it_sends_nothing(tmp_path): + log = _run(_start("fake.status.on = false;"), tmp_path) + assert _of(log, "plan") == [] and _of(log, "hub") == [] + + # ── nothing is ever removed ───────────────────────────────────────────────── def test_the_backup_has_no_way_to_remove_anything_on_the_node(): diff --git a/packages/meshbay-hub/tests/test_photo_sync.py b/packages/meshbay-hub/tests/test_photo_sync.py index cbc5f5d..98f49ba 100644 --- a/packages/meshbay-hub/tests/test_photo_sync.py +++ b/packages/meshbay-hub/tests/test_photo_sync.py @@ -43,7 +43,14 @@ export class ConnectionPool { PRELUDE = r""" const fake = globalThis.fake = { log: [], pools: [], refuseConnect: null, uploadError: null, - afterUpload: null }; + afterUpload: null, adminId: 'u-bob', membersError: null, + members: [{ user_id: 'u-bob', username: 'bob' }], invited: [] }; +// The hub's member list, asked before every run reads anything. +const hubFetch = async (path) => { + fake.log.push(['hub', path]); + if (fake.membersError) throw new Error(fake.membersError); + return { admin_id: fake.adminId, members: fake.members, invited: fake.invited }; +}; const win = new EventTarget(); const doc = new EventTarget(); doc.visibilityState = 'visible'; @@ -58,8 +65,9 @@ globalThis.fetch = async (url) => { 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 }, + config: { albums: ['cam'], includeExisting: true, since: 0 }, + destination: { account: 'bob', groupId: 'g1', groupName: 'Mine', folder: 'Media/Photos/Bob' }, + dir: 'Media/Photos/Bob/bob-photos', lastCompleted: null, failure: null, failureAt: null, }; fake.items = [ @@ -116,7 +124,7 @@ def _run(body, tmp_path): 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', " + + "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. @@ -274,23 +282,31 @@ def test_a_folder_no_longer_writable_is_found_before_sending(tmp_path): def test_another_account_on_the_phone_sends_nothing(tmp_path): - log = _run(_start("fake.status.config.account = 'carol';"), tmp_path) + log = _run(_start("fake.status.destination.account = 'carol';"), tmp_path) assert ["plan"] not in log -# ── choosing a folder ─────────────────────────────────────────────────────── +# ── the account's own group, at every run ─────────────────────────────────── -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"]]] +@pytest.mark.parametrize("setup", [ + "fake.members.push({ user_id: 'u-carol', username: 'carol' });", + "fake.invited = [{ user_id: 'u-carol', username: 'carol' }];", + "fake.adminId = 'u-carol';", +]) +def test_a_group_not_ones_own_alone_stops_it_before_anything_is_read(tmp_path, setup): + log = _run(_start(setup), tmp_path) + assert _of(log, "failed") == [["not_private"]] + assert ["plan"] not in log and _of(log, "upload") == [] + + +def test_the_group_is_asked_about_before_the_photos_are_listed(tmp_path): + log = _run(_start(), tmp_path) + assert log.index(["hub", "/v1/groups/g1/members"]) < log.index(["plan"]) + + +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 ["plan"] not in log # ── nothing is ever removed ───────────────────────────────────────────────── diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py index 6222b26..bcd26c8 100644 --- a/packages/meshbay-hub/tests/test_photo_sync_ui.py +++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py @@ -53,3 +53,16 @@ def test_a_refusal_is_said_where_the_backup_is(probe): def test_limited_access_is_said_rather_than_failing_quietly(probe): assert "Access is limited to the photos you selected" in probe["partial360"]["text"] + + +def test_where_backups_go_lists_each_kinds_folder(probe): + text = probe["dest360"]["text"] + assert "Media/bob-photos" in text and "Media/bob-contacts" in text + + +def test_photos_the_photos_tab_will_not_show_are_said(probe): + assert "does not show Media/bob-photos" in probe["dest360"]["text"] + + +def test_the_contacts_backup_says_where_its_copies_go(probe): + assert "bob-contacts/contacts-2026-10-10-0900.vcf" in probe["configured360"]["text"] |