Extract the operational Model Delivery PWA
This commit is contained in:
commit
958f8d5ff1
46 files changed
+6446
No files matched your search
@@ -0,0 +1,28 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import type { LocalDelivery } from '../../src/db';
|
||||
import { activeDeliveries, deliveriesForDate, deliveryDateKey, toDateKey, valueOf, weekDates } from '../../src/analytics';
|
||||
|
||||
function delivery(overrides: Partial<LocalDelivery> = {}): LocalDelivery {
|
||||
return {
|
||||
id: crypto.randomUUID(), customerId: crypto.randomUUID(), addressId: crypto.randomUUID(), deliveryTypeId: crypto.randomUUID(),
|
||||
deliveryTypeVersion: 1, shipmentUnitHundredths: 100, unitPriceAgorot: 1000, totalPriceAgorot: 1000,
|
||||
deliveryNumber: 1001, scheduledDate: '2026-09-06', deliverySlot: 'afternoon', specificTime: null, deliveryWindow: null, notes: '', status: 'created', version: 1,
|
||||
createdAt: '2026-09-04T10:00:00.000Z', updatedAt: null, cancelledAt: null, cancellationReason: null,
|
||||
deletedAt: null, syncState: 'synced', ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('builds an Israeli Sunday-to-Saturday week without timezone drift', () => {
|
||||
const days = weekDates(new Date(2026, 8, 9, 23));
|
||||
expect(days.map(toDateKey)).toEqual(['2026-09-06', '2026-09-07', '2026-09-08', '2026-09-09', '2026-09-10', '2026-09-11', '2026-09-12']);
|
||||
});
|
||||
|
||||
it('uses the planned date and excludes cancelled deliveries from value totals', () => {
|
||||
const legacy = delivery({ scheduledDate: null });
|
||||
const active = delivery();
|
||||
const cancelled = delivery({ status: 'cancelled', totalPriceAgorot: 5000 });
|
||||
expect(deliveryDateKey(legacy)).toBe('2026-09-04');
|
||||
expect(deliveriesForDate([legacy, active, cancelled], '2026-09-06')).toHaveLength(2);
|
||||
expect(activeDeliveries([active, cancelled])).toEqual([active]);
|
||||
expect(valueOf([active, cancelled])).toBe(1000);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest';
|
||||
import { deliverySchema, type Change } from '@model/shared';
|
||||
import { ModelDatabase, changeDelivery, resolveConflict, storeChange } from '../../src/db';
|
||||
import { AuthenticationError, runSync } from '../../src/sync';
|
||||
|
||||
let database: ModelDatabase;
|
||||
const customerId = crypto.randomUUID(), addressId = crypto.randomUUID(), id = crypto.randomUUID();
|
||||
const meta = { createdAt: new Date().toISOString(), deletedAt: null, version: 1 };
|
||||
const original = deliverySchema.parse({ ...meta, id, customerId, addressId, deliverySlot: 'morning', specificTime: null, deliveryWindow: { start: '08:00', end: '11:00' }, deliveryTypeId: crypto.randomUUID(), deliveryTypeVersion: 1, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, totalPriceAgorot: 1502, deliveryNumber: 1201, notes: 'Original', status: 'created' });
|
||||
beforeEach(async () => {
|
||||
database = new ModelDatabase(`changes-${crypto.randomUUID()}`);
|
||||
await storeChange(database, { entityType: 'customer', record: { ...meta, id: customerId, name: 'Test' } });
|
||||
await storeChange(database, { entityType: 'address', record: { ...meta, id: addressId, customerId, label: 'Main', address: 'Test address' } });
|
||||
await storeChange(database, { entityType: 'delivery', record: original });
|
||||
});
|
||||
afterEach(async () => { await database.delete(); });
|
||||
it('persists an offline edit with the original base version and immutable pricing', async () => {
|
||||
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, notes: 'Offline edit' }, database);
|
||||
database.close(); await database.open();
|
||||
expect(await database.deliveries.get(id)).toMatchObject({ notes: 'Offline edit', deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, syncState: 'pending', version: 1, deliveryNumber: 1201, totalPriceAgorot: 1502 });
|
||||
expect((await database.outbox.toArray())[0]!.operation.baseVersion).toBe(1);
|
||||
await expect(changeDelivery(id, 1, 'delivery.cancel', { reason: 'Too soon' }, database)).rejects.toThrow('סנכרון');
|
||||
});
|
||||
it('rejects a stale edit form instead of overwriting a newer pulled record', async () => {
|
||||
await storeChange(database, { entityType: 'delivery', record: { ...original, version: 2, notes: 'Remote' } });
|
||||
await expect(changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'Stale form' }, database)).rejects.toThrow('השתנה');
|
||||
expect(await database.outbox.count()).toBe(0);
|
||||
});
|
||||
it('cancels locally without removing the record, business number or snapshot', async () => {
|
||||
await changeDelivery(id, 1, 'delivery.cancel', { reason: 'Customer requested' }, database);
|
||||
expect(await database.deliveries.get(id)).toMatchObject({ status: 'cancelled', deliveryNumber: 1201, unitPriceAgorot: 1001, cancellationReason: 'Customer requested', syncState: 'pending' });
|
||||
});
|
||||
it('keeps the exact pending operation on 401 rather than marking it rejected', async () => {
|
||||
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'Waiting for login' }, database);
|
||||
const entry = (await database.outbox.toArray())[0]!;
|
||||
await expect(runSync(database, async () => Response.json({ code: 'AUTH_REQUIRED' }, { status: 401 }))).rejects.toBeInstanceOf(AuthenticationError);
|
||||
expect((await database.outbox.toArray())[0]).toEqual(entry);
|
||||
});
|
||||
it('requires explicit resolution, resubmits with a new operation ID, and preserves the local intended patch', async () => {
|
||||
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'My edit' }, database);
|
||||
const op = (await database.outbox.toArray())[0]!;
|
||||
const server: Change = { entityType: 'delivery', record: { ...original, version: 2, notes: 'Another device' } };
|
||||
await runSync(database, async url => url.endsWith('/push') ? Response.json({ code: 'VERSION_CONFLICT', current: server }, { status: 409 }) : Response.json({ changes: [{ sequence: '2', change: server }], nextCursor: '2', hasMore: false }));
|
||||
expect((await database.deliveries.get(id))?.notes).toBe('My edit');
|
||||
await expect(resolveConflict(op.operationId, 1, 'local', database)).rejects.toThrow('חדשה');
|
||||
await resolveConflict(op.operationId, 2, 'local', database);
|
||||
const replacement = (await database.outbox.toArray())[0]!;
|
||||
expect(replacement.operationId).not.toBe(op.operationId);
|
||||
expect(replacement.operation.baseVersion).toBe(2);
|
||||
expect(replacement.operation.data).toMatchObject({ notes: 'My edit' });
|
||||
});
|
||||
it('accepts server cancellation explicitly and refuses to resurrect it', async () => {
|
||||
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'My edit' }, database);
|
||||
const op = (await database.outbox.toArray())[0]!;
|
||||
const server = { ...original, version: 2, status: 'cancelled' as const, cancellationReason: 'Closed', cancelledAt: new Date().toISOString() };
|
||||
await database.outbox.update(op.queueId!, { state: 'error', serverConflict: { entityType: 'delivery', record: server } });
|
||||
await expect(resolveConflict(op.operationId, 2, 'local', database)).rejects.toThrow();
|
||||
await resolveConflict(op.operationId, 2, 'server', database);
|
||||
expect(await database.outbox.count()).toBe(0);
|
||||
expect(await database.deliveries.get(id)).toMatchObject({ status: 'cancelled', syncState: 'synced', version: 2 });
|
||||
await storeChange(database, { entityType: 'delivery', record: original });
|
||||
expect((await database.deliveries.get(id))?.status).toBe('cancelled');
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user