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