From c77bbe3bba82ee0915b95e6da9c87af3334716a1 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 15 Jun 2026 17:29:23 +0200 Subject: [PATCH] test(backup): cover Android KV read-degradation + write-path bail (#8402) Adds CI-runnable Karma specs for the resilience logic from the fix commit: read degrades to null (never throws), and the write path bails without overwriting when the existing-slot read fails (preserving both ring slots). To make the window.SUPAndroid singleton (undefined off-device, no DI seam) testable, the native calls now route through thin _nativeDbLoad/_nativeDbSave methods the specs spy. The real ~2MB CursorWindow limit remains covered by KeyValStoreInstrumentedTest (emulator-only). --- .../local-backup/local-backup.service.spec.ts | 76 +++++++++++++++++++ .../imex/local-backup/local-backup.service.ts | 19 ++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/app/imex/local-backup/local-backup.service.spec.ts b/src/app/imex/local-backup/local-backup.service.spec.ts index 5c2d16d447..a1ed38f263 100644 --- a/src/app/imex/local-backup/local-backup.service.spec.ts +++ b/src/app/imex/local-backup/local-backup.service.spec.ts @@ -952,4 +952,80 @@ describe('LocalBackupService', () => { expect(service.getLastBackupTime()).toBeNull(); }); }); + + // PR #8402: Android native-KV read/write resilience. These spy the `_nativeDb*` + // seams (thin wrappers over the window.SUPAndroid singleton, which is undefined + // off-device) so the read-degradation + write-path bail logic runs in CI without + // an emulator. The real CursorWindow limit is covered by KeyValStoreInstrumentedTest. + describe('Android native KV read/write resilience (#8402)', () => { + type NativeSeams = { + _backupAndroid: (data: AppDataComplete) => Promise; + _loadAndroidDbValueSafe: (key: string) => Promise; + _nativeDbLoad: (key: string) => Promise; + _nativeDbSave: (key: string, value: string) => Promise; + }; + // Ring-slot keys — module-private constants in the service under test. + const PRIMARY = 'backup'; + const PREV = 'backup_prev'; + // >= 3 tasks so the A3 near-empty guard (#7925) never fires here. + const dataWith3Tasks = { + task: { + ids: ['t1', 't2', 't3'], + entities: { + t1: { id: 't1', title: 'a' }, + t2: { id: 't2', title: 'b' }, + t3: { id: 't3', title: 'c' }, + }, + }, + archiveYoung: DEFAULT_ARCHIVE, + archiveOld: DEFAULT_ARCHIVE, + project: { ids: [], entities: {} }, + tag: { ids: [], entities: {} }, + } as unknown as AppDataComplete; + const seams = (): NativeSeams => service as unknown as NativeSeams; + + it('read degrades to null (never throws) when the native read fails', async () => { + spyOn(seams(), '_nativeDbLoad').and.rejectWith(new Error('Java exception')); + + expect(await seams()._loadAndroidDbValueSafe(PRIMARY)).toBeNull(); + }); + + it('read passes the value through on success', async () => { + spyOn(seams(), '_nativeDbLoad').and.resolveTo('the-backup-blob'); + + expect(await seams()._loadAndroidDbValueSafe(PRIMARY)).toBe('the-backup-blob'); + }); + + it('write BAILS (no overwrite) when the existing-slot read fails — preserves both ring slots (#7901)', async () => { + spyOn(seams(), '_nativeDbLoad').and.rejectWith(new Error('read failed')); + const saveSpy = spyOn(seams(), '_nativeDbSave').and.resolveTo(); + + const didWrite = await seams()._backupAndroid(dataWith3Tasks); + + expect(didWrite).toBe(false); + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it('write rotates prev then writes primary when an existing backup is present', async () => { + spyOn(seams(), '_nativeDbLoad').and.resolveTo('EXISTING_BLOB'); + const saveSpy = spyOn(seams(), '_nativeDbSave').and.resolveTo(); + + const didWrite = await seams()._backupAndroid(dataWith3Tasks); + + expect(didWrite).toBe(true); + expect(saveSpy).toHaveBeenCalledWith(PREV, 'EXISTING_BLOB'); + expect(saveSpy).toHaveBeenCalledWith(PRIMARY, JSON.stringify(dataWith3Tasks)); + }); + + it('write skips rotation and writes primary when there is no existing backup', async () => { + spyOn(seams(), '_nativeDbLoad').and.resolveTo(null); + const saveSpy = spyOn(seams(), '_nativeDbSave').and.resolveTo(); + + const didWrite = await seams()._backupAndroid(dataWith3Tasks); + + expect(didWrite).toBe(true); + expect(saveSpy).toHaveBeenCalledOnceWith(PRIMARY, JSON.stringify(dataWith3Tasks)); + expect(saveSpy).not.toHaveBeenCalledWith(PREV, jasmine.anything()); + }); + }); }); diff --git a/src/app/imex/local-backup/local-backup.service.ts b/src/app/imex/local-backup/local-backup.service.ts index 948122148c..9d3edf0e2d 100644 --- a/src/app/imex/local-backup/local-backup.service.ts +++ b/src/app/imex/local-backup/local-backup.service.ts @@ -129,7 +129,7 @@ export class LocalBackupService { */ private async _loadAndroidDbValueSafe(key: string): Promise { try { - const val = await androidInterface.loadFromDbWrapped(key); + const val = await this._nativeDbLoad(key); Log.log( `LocalBackupService: read Android backup '${key}' (${val ? val.length : 0} chars)`, ); @@ -140,6 +140,17 @@ export class LocalBackupService { } } + // Thin seams over the `androidInterface` (window.SUPAndroid) singleton — which + // is undefined off-device and has no DI seam — so the read/write logic above is + // unit-testable (spec spies these instead of the global). + private _nativeDbLoad(key: string): Promise { + return androidInterface.loadFromDbWrapped(key); + } + + private _nativeDbSave(key: string, value: string): Promise { + return androidInterface.saveToDbWrapped(key, value); + } + async loadBackupIOS(): Promise { const [primary, prev] = await Promise.all([ this._readIOSFileOrNull(IOS_BACKUP_FILENAME), @@ -407,7 +418,7 @@ export class LocalBackupService { // preserving both ring slots; the next cycle retries (#7901). let existing: string | null; try { - existing = await androidInterface.loadFromDbWrapped(ANDROID_DB_KEY); + existing = await this._nativeDbLoad(ANDROID_DB_KEY); } catch (e) { Log.err( 'LocalBackupService: skipping Android backup — could not read existing slot', @@ -419,12 +430,12 @@ export class LocalBackupService { return false; } if (existing) { - await androidInterface.saveToDbWrapped(ANDROID_DB_KEY_PREV, existing); + await this._nativeDbSave(ANDROID_DB_KEY_PREV, existing); } const json = JSON.stringify(data); // Size only, never content (core/log rule 9) — lands in shared log exports. Log.log(`LocalBackupService: writing Android backup (${json.length} chars)`); - await androidInterface.saveToDbWrapped(ANDROID_DB_KEY, json); + await this._nativeDbSave(ANDROID_DB_KEY, json); return true; }