fix(sync): guard TransactionSyncManager against missing IndexedDB (PHP-LARAVEL-43)

Route every Dexie read/write through withDb so a missing or broken IndexedDB
degrades gracefully instead of throwing: reads return empty values, writes and
clearAll no-op, and sync() returns a skipped result without the pointless server
round-trip. This stops the unhandled "Can't find variable: indexedDB" rejection
that fired on browsers without IndexedDB (e.g. Chrome on iOS), including the
clearAll() call on the register page.
This commit is contained in:
Víctor Falcón 2026-07-07 06:50:05 +02:00
parent d81d5174fd
commit 80f99a9f75
2 changed files with 135 additions and 15 deletions

View File

@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TransactionSyncManager } from './sync-manager';
const dbMock = vi.hoisted(() => ({
transactions: {
toArray: vi.fn(),
get: vi.fn(),
clear: vi.fn(),
bulkPut: vi.fn(),
where: vi.fn(() => ({
equals: () => ({ toArray: vi.fn(async () => []) }),
})),
},
sync_metadata: {
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
// Keep the real isIndexedDbAvailable + withDb (they read globalThis live); only
// swap the Dexie-backed `db` for spies so no real IndexedDB is needed.
vi.mock('./dexie-db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./dexie-db')>();
return { ...actual, db: dbMock };
});
vi.mock('axios', () => ({ default: { get: vi.fn() } }));
function makeManager(): TransactionSyncManager {
return new TransactionSyncManager({ endpoint: '/api/sync/transactions' });
}
describe('TransactionSyncManager without IndexedDB', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('indexedDB', undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('clearAll resolves without touching the database or throwing', async () => {
await expect(makeManager().clearAll()).resolves.toBeUndefined();
expect(dbMock.transactions.clear).not.toHaveBeenCalled();
expect(dbMock.sync_metadata.delete).not.toHaveBeenCalled();
});
it('sync returns a skipped result without a server round-trip', async () => {
const axios = (await import('axios')).default;
const result = await makeManager().sync();
expect(result).toEqual({
success: true,
inserted: 0,
updated: 0,
errors: [],
});
expect(axios.get).not.toHaveBeenCalled();
});
it('reads degrade to empty values instead of throwing', async () => {
const manager = makeManager();
await expect(manager.getAll()).resolves.toEqual([]);
await expect(manager.getById('id-1')).resolves.toBeNull();
await expect(manager.getByAccountId('acc-1')).resolves.toEqual([]);
await expect(manager.getLastSyncTime()).resolves.toBeNull();
await expect(manager.setLastSyncTime('now')).resolves.toBeUndefined();
expect(dbMock.transactions.toArray).not.toHaveBeenCalled();
expect(dbMock.sync_metadata.get).not.toHaveBeenCalled();
});
});
describe('TransactionSyncManager with IndexedDB', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('indexedDB', {} as IDBFactory);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('clearAll clears the transactions and sync metadata', async () => {
await makeManager().clearAll();
expect(dbMock.transactions.clear).toHaveBeenCalledTimes(1);
expect(dbMock.sync_metadata.delete).toHaveBeenCalledWith(
'last_sync_transactions',
);
});
it('getAll passes through the stored transactions', async () => {
const stored = [{ id: 't1' }];
dbMock.transactions.toArray.mockResolvedValueOnce(stored);
await expect(makeManager().getAll()).resolves.toBe(stored);
});
});

View File

@ -2,7 +2,7 @@ import type { Transaction } from '@/types/transaction';
import type { UUID } from '@/types/uuid';
import { __ } from '@/utils/i18n';
import axios from 'axios';
import { db } from './dexie-db';
import { db, isIndexedDbAvailable, withDb } from './dexie-db';
export interface SyncResult {
success: boolean;
@ -29,18 +29,29 @@ export class TransactionSyncManager {
}
async getLastSyncTime(): Promise<string | null> {
const metadata = await db.sync_metadata.get(LAST_SYNC_KEY);
return metadata?.value || null;
return withDb(async () => {
const metadata = await db.sync_metadata.get(LAST_SYNC_KEY);
return metadata?.value || null;
}, null);
}
async setLastSyncTime(timestamp: string): Promise<void> {
await db.sync_metadata.put({
key: LAST_SYNC_KEY,
value: timestamp,
});
await withDb<void>(async () => {
await db.sync_metadata.put({
key: LAST_SYNC_KEY,
value: timestamp,
});
}, undefined);
}
async sync(): Promise<SyncResult> {
// No offline store to sync into (see PHP-LARAVEL-43). Skip cleanly —
// empty errors so callers do not treat it as a failure — and avoid the
// pointless server round-trip that would only populate a missing cache.
if (!isIndexedDbAvailable()) {
return { success: true, inserted: 0, updated: 0, errors: [] };
}
if (this.syncInProgress) {
return {
success: false,
@ -129,18 +140,22 @@ export class TransactionSyncManager {
}
async getAll(): Promise<Transaction[]> {
return await db.transactions.toArray();
return withDb<Transaction[]>(() => db.transactions.toArray(), []);
}
async getById(id: UUID): Promise<Transaction | null> {
return (await db.transactions.get(id)) || null;
return withDb<Transaction | null>(
async () => (await db.transactions.get(id)) || null,
null,
);
}
async getByAccountId(accountId: UUID): Promise<Transaction[]> {
return await db.transactions
.where('account_id')
.equals(accountId)
.toArray();
return withDb<Transaction[]>(
() =>
db.transactions.where('account_id').equals(accountId).toArray(),
[],
);
}
isSyncing(): boolean {
@ -148,7 +163,9 @@ export class TransactionSyncManager {
}
async clearAll(): Promise<void> {
await db.transactions.clear();
await db.sync_metadata.delete(LAST_SYNC_KEY);
await withDb<void>(async () => {
await db.transactions.clear();
await db.sync_metadata.delete(LAST_SYNC_KEY);
}, undefined);
}
}