mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
c11f201f47
commit
c77bbe3bba
2 changed files with 91 additions and 4 deletions
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue