import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { changeCustomer, ModelDatabase, resolveCustomerConflict, saveLocal, storeChange } from '../../src/db'; import { createSyncCoordinator, runSync, type Fetcher } from '../../src/sync'; import { operationSchema, totalPrice, type Change } from '@model/shared'; let db: ModelDatabase; const customerId = crypto.randomUUID(); const addressId = crypto.randomUUID(); const typeId = crypto.randomUUID(); const meta = { version: 1, createdAt: new Date().toISOString(), deletedAt: null, updatedAt: null, cancelledAt: null, cancellationReason: null }; const deliveryData = { customerId, addressId, scheduledDate: null, deliverySlot: 'morning' as const, specificTime: null, deliveryWindow: { start: '08:00', end: '11:00' }, deliveryTypeId: typeId, deliveryTypeVersion: 1, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, notes: 'בדיקה' }; beforeEach(async () => { db = new ModelDatabase(`test-${crypto.randomUUID()}`); await storeChange(db, { entityType: 'customer', record: { id: customerId, name: 'Test', ...meta } }); await storeChange(db, { entityType: 'address', record: { id: addressId, customerId, label: 'Main', address: 'Test address', ...meta } }); }); afterEach(async () => { await db.delete(); }); describe('durable local writes and recovery', () => { it('honors reconnect while an earlier offline request is still failing', async () => { let rejectFirst!: (error: Error) => void; const firstRequest = new Promise((_resolve, reject) => { rejectFirst = reject; }); let attempts = 0; const sync = createSyncCoordinator(async () => { if (++attempts === 1) await firstRequest; }); const startup = sync(); const reconnect = sync(); rejectFirst(new Error('The old offline request failed')); await expect(startup).resolves.toBeUndefined(); await expect(reconnect).resolves.toBeUndefined(); expect(attempts).toBe(2); }); it('saves entity and outbox offline, survives reopen and reuses the same operation after lost response', async () => { const id = await saveLocal('delivery.create', deliveryData, db); const [entry] = await db.outbox.toArray(); expect(await db.deliveries.get(id)).toMatchObject({ deliveryNumber: null, syncState: 'pending', totalPriceAgorot: 1502, deliverySlot: 'morning', deliveryWindow: { start: '08:00', end: '11:00' } }); db.close(); await db.open(); const attempts: string[] = []; const change: Change = { entityType: 'delivery', record: { ...deliveryData, ...meta, id, deliveryNumber: 1001, totalPriceAgorot: 1502, status: 'created' } }; const lostResponse: Fetcher = async (_url, init) => { attempts.push(JSON.parse(String(init?.body)).operationId); throw new Error('Response lost after commit'); }; await expect(runSync(db, lostResponse)).rejects.toThrow(); expect(await db.outbox.count()).toBe(1); const replay: Fetcher = async (url, init) => { if (url.endsWith('/push')) { attempts.push(JSON.parse(String(init?.body)).operationId); return Response.json({ change, replayed: true }); } expect(url).toContain('cursor=0'); return Response.json({ changes: [{ sequence: '1', change }], nextCursor: '1', hasMore: false }); }; await runSync(db, replay); expect(attempts).toEqual([entry!.operationId, entry!.operationId]); expect(await db.outbox.count()).toBe(0); expect(await db.deliveries.get(id)).toMatchObject({ id, deliveryNumber: 1001, syncState: 'synced' }); }); it('rolls back the local entity if the outbox insert fails', async () => { db.outbox.hook('creating', () => { throw new Error('Disk full'); }); await expect(saveLocal('delivery.create', deliveryData, db)).rejects.toThrow('Disk full'); expect(await db.deliveries.count()).toBe(0); expect(await db.outbox.count()).toBe(0); }); it('rejects addresses assigned to another customer without saving an operation', async () => { await expect(saveLocal('delivery.create', { ...deliveryData, customerId: crypto.randomUUID() }, db)).rejects.toThrow(); expect(await db.outbox.count()).toBe(0); }); it('preserves a conflicting draft when the pull feed contains the server copy', async () => { const id = await saveLocal('delivery.create', deliveryData, db); const change: Change = { entityType: 'delivery', record: { ...deliveryData, ...meta, id, notes: 'Different server copy', deliveryNumber: 2001, totalPriceAgorot: 1502, status: 'created' } }; await runSync(db, async url => url.endsWith('/push') ? Response.json({ code: 'VERSION_CONFLICT', current: change }, { status: 409 }) : Response.json({ changes: [{ sequence: '1', change }], nextCursor: '1', hasMore: false })); expect(await db.deliveries.get(id)).toMatchObject({ notes: 'בדיקה', syncState: 'error', deliveryNumber: null }); expect((await db.outbox.toArray())[0]?.serverConflict).toEqual(change); }); it('applies tombstones and paginated changes with a durable cursor', async () => { const first: Change = { entityType: 'customer', record: { id: customerId, name: 'Deleted', createdAt: meta.createdAt, version: 2, deletedAt: meta.createdAt } }; const second: Change = { entityType: 'address', record: { id: addressId, customerId, label: 'Deleted', address: 'Test', createdAt: meta.createdAt, version: 2, deletedAt: meta.createdAt } }; const cursors: string[] = []; await runSync(db, async url => { cursors.push(url); return Response.json(url.endsWith('=0') ? { changes: [{ sequence: '5', change: first }], nextCursor: '5', hasMore: true } : { changes: [{ sequence: '8', change: second }], nextCursor: '8', hasMore: false }); }); expect(cursors).toEqual(['/api/sync/pull?cursor=0', '/api/sync/pull?cursor=5']); expect((await db.customers.get(customerId))?.deletedAt).toBe(meta.createdAt); expect((await db.meta.get('cursor'))?.value).toBe('8'); }); it('rejects nonzero create versions and rounds fixed-point totals', () => { expect(operationSchema.safeParse({ baseVersion: 1, kind: 'delivery.create' }).success).toBe(false); expect(operationSchema.safeParse({ operationId: crypto.randomUUID(), deviceId: crypto.randomUUID(), entityId: crypto.randomUUID(), baseVersion: 0, kind: 'delivery.create', data: { ...deliveryData, deliverySlot: 'custom', specificTime: null, deliveryWindow: null } }).success).toBe(false); expect(totalPrice(101, 150)).toBe(152); }); it('stores customer edits and archives atomically, with explicit conflict resolution', async () => { const details = { name: 'Updated clinic', contactName: 'תום רוף', phone: '050-1234567', comments: 'Ring at reception', deliveryWindows: { morning: { start: '08:30', end: '11:45' }, afternoon: null, lateNight: null } }; await changeCustomer(customerId, 1, 'customer.update', details, db); const pending = (await db.outbox.toArray())[0]!; expect(await db.customers.get(customerId)).toMatchObject({ ...details, syncState: 'pending' }); const server: Change = { entityType: 'customer', record: { id: customerId, ...details, name: 'Server clinic', version: 2, createdAt: meta.createdAt, deletedAt: null, updatedAt: meta.createdAt } }; await db.outbox.update(pending.queueId!, { state: 'error', serverConflict: server }); await resolveCustomerConflict(pending.operationId, 2, 'local', db); const retry = (await db.outbox.toArray())[0]!; expect(retry.operation).toMatchObject({ kind: 'customer.update', baseVersion: 2, data: details }); await db.outbox.clear(); await storeChange(db, server); await changeCustomer(customerId, 2, 'customer.remove', {}, db); expect((await db.customers.get(customerId))?.deletedAt).toBeTruthy(); expect((await db.outbox.toArray())[0]?.operation.kind).toBe('customer.remove'); }); });