aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 14:21:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 14:21:46 +0200
commit2f2a9a6542d2194e50ffba6f382e4fdffd481838 (patch)
tree5437330f9e8b963f8af0904f3c5d80d1afc6dc3b /packages/meshbay-hub/tests
parent914d2f0af848fb80d70d517901f44420a4e47764 (diff)
downloadmeshbay-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.py40
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py4
-rw-r--r--packages/meshbay-hub/tests/test_first_load_is_lean.py2
-rw-r--r--packages/meshbay-hub/tests/test_phone_sync.py45
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync.py50
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync_ui.py13
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"]