Files
delivery-app-web/tests/unit/local-sync.test.ts
T

103 lines
7.7 KiB
TypeScript

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<void>((_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');
});
});