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).
This commit is contained in:
Johannes Millan 2026-06-15 17:29:23 +02:00
parent c11f201f47
commit c77bbe3bba
2 changed files with 91 additions and 4 deletions

View file

@ -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<boolean>;
_loadAndroidDbValueSafe: (key: string) => Promise<string | null>;
_nativeDbLoad: (key: string) => Promise<string | null>;
_nativeDbSave: (key: string, value: string) => Promise<void>;
};
// 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());
});
});
});

View file

@ -129,7 +129,7 @@ export class LocalBackupService {
*/
private async _loadAndroidDbValueSafe(key: string): Promise<string | null> {
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<string | null> {
return androidInterface.loadFromDbWrapped(key);
}
private _nativeDbSave(key: string, value: string): Promise<void> {
return androidInterface.saveToDbWrapped(key, value);
}
async loadBackupIOS(): Promise<string> {
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;
}