feat(sync): include avatar in sync snapshot + auto-backup (multi-device)

The profile picture (users.avatar_b64, cosmetic/unencrypted) travelled only
in the manual export. Now it's also in buildSyncSnapshot and the auto-backup
container, so a new device / a restore picks it up.

- Restore is ADDITIVE (mirrors the import path): applyRemoteSnapshot adopts
  remote.avatar_b64 only when the local device has no avatar — never clobbers
  a locally-set picture. No per-avatar timestamp to arbitrate, so changing an
  existing avatar doesn't propagate (cosmetic, accepted).
- +2 merge tests (adopt-when-empty, don't-clobber-when-set). 65/65 green.
- Server /avatar endpoint unchanged (already accepts {avatar_b64}).

Closes the avatar item of CODE_AUDIT §4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-08 20:06:31 +01:00
parent dd86b2bd23
commit c58424d58c
5 changed files with 56 additions and 3 deletions
+26
View File
@@ -49,6 +49,10 @@ async function freshMerge() {
db.folders.push({ name: body.name, color: body.color || '', icon: body.icon || '' });
return { ok: true };
}
if (path === '/avatar' && method === 'POST') {
db.avatar = body.avatar_b64 || '';
return { ok: true };
}
if (path === '/entries/tombstones' && method === 'GET') {
return db.tombstones.map(t => Object.assign({}, t));
}
@@ -220,6 +224,28 @@ test('merge: missing remote folders are added additively', async () => {
assert.equal(added.color, '#0f0');
});
test('merge: remote avatar is adopted when the local device has none', async () => {
const { ctx, T, db } = await freshMerge();
ctx.__test.state.avatarDataUri = ''; // this device has no picture
await T.applyRemoteSnapshot({
entries: [], tombstones: [], folders: [],
avatar_b64: 'data:image/jpeg;base64,AAAA',
});
assert.equal(db.avatar, 'data:image/jpeg;base64,AAAA', 'avatar POSTed to server');
assert.equal(ctx.__test.state.avatarDataUri, 'data:image/jpeg;base64,AAAA');
});
test('merge: remote avatar does NOT clobber an existing local avatar (additive)', async () => {
const { ctx, T, db } = await freshMerge();
ctx.__test.state.avatarDataUri = 'data:image/jpeg;base64,LOCAL';
await T.applyRemoteSnapshot({
entries: [], tombstones: [], folders: [],
avatar_b64: 'data:image/jpeg;base64,REMOTE',
});
assert.equal(db.avatar, undefined, 'no /avatar POST when a local avatar exists');
assert.equal(ctx.__test.state.avatarDataUri, 'data:image/jpeg;base64,LOCAL');
});
test('merge: empty/invalid remote snapshot is a no-op', async () => {
const { T, db } = await freshMerge();
db.seedEntry({ uuid: 'u1' });