diff --git a/resources/js/lib/sync-manager.test.ts b/resources/js/lib/sync-manager.test.ts new file mode 100644 index 00000000..a4405124 --- /dev/null +++ b/resources/js/lib/sync-manager.test.ts @@ -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(); + 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); + }); +}); diff --git a/resources/js/lib/sync-manager.ts b/resources/js/lib/sync-manager.ts index 15eb7d97..b6a45cec 100644 --- a/resources/js/lib/sync-manager.ts +++ b/resources/js/lib/sync-manager.ts @@ -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 { - 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 { - await db.sync_metadata.put({ - key: LAST_SYNC_KEY, - value: timestamp, - }); + await withDb(async () => { + await db.sync_metadata.put({ + key: LAST_SYNC_KEY, + value: timestamp, + }); + }, undefined); } async sync(): Promise { + // 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 { - return await db.transactions.toArray(); + return withDb(() => db.transactions.toArray(), []); } async getById(id: UUID): Promise { - return (await db.transactions.get(id)) || null; + return withDb( + async () => (await db.transactions.get(id)) || null, + null, + ); } async getByAccountId(accountId: UUID): Promise { - return await db.transactions - .where('account_id') - .equals(accountId) - .toArray(); + return withDb( + () => + db.transactions.where('account_id').equals(accountId).toArray(), + [], + ); } isSyncing(): boolean { @@ -148,7 +163,9 @@ export class TransactionSyncManager { } async clearAll(): Promise { - await db.transactions.clear(); - await db.sync_metadata.delete(LAST_SYNC_KEY); + await withDb(async () => { + await db.transactions.clear(); + await db.sync_metadata.delete(LAST_SYNC_KEY); + }, undefined); } }