import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'node:crypto'; import { operationSchema, type Operation } from '@model/shared'; import { createPool } from '../../src/db'; import { buildApp } from '../../src/app'; import { testAccount } from '../helpers/account'; const pool = createPool(); const app = buildApp(pool, false); const deviceId = randomUUID(); const customerId = randomUUID(); const addressId = randomUUID(); function operation(kind: Operation['kind'], data: unknown, entityId = randomUUID()) { return operationSchema.parse({ operationId: randomUUID(), deviceId, entityId, kind, baseVersion: 0, data }); } let headers: Record = { 'x-model-request': '1' }; const push = (payload: Operation) => app.inject({ method: 'POST', url: '/sync/push', payload, headers }); const get = (url: string) => app.inject({ url, headers }); const deliveryData = { customerId, addressId, scheduledDate: '2026-09-06', deliverySlot: 'morning' as const, specificTime: null, deliveryWindow: { start: '08:00', end: '11:00' }, deliveryTypeId: '10000000-0000-4000-8000-000000000003', deliveryTypeVersion: 1, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, notes: 'Integration test' }; beforeAll(async () => { expect(process.env.DB_NAME).toBe('model_delivery_test'); const state = (await app.inject('/local-admin/status')).json(); const login = await app.inject({ method: 'POST', url: state.setupRequired ? '/local-admin/setup' : '/local-admin/login', headers, payload: state.setupRequired ? { ...testAccount, setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } : { username: testAccount.username, password: testAccount.password } }); expect(login.statusCode).toBe(200); headers = { ...headers, cookie: login.cookies.map(c => `${c.name}=${c.value}`).join('; '), 'x-model-user': login.json().user.id }; expect((await push(operation('customer.create', { name: 'Integration test customer' }, customerId))).statusCode).toBe(200); expect((await push(operation('address.create', { customerId, label: 'Test', address: 'Test address' }, addressId))).statusCode).toBe(200); }); afterAll(async () => { await app.close(); await pool.end(); }); describe('MariaDB-backed API', () => { it('requires authentication and rejects a different account or unsafe request origin', async () => { expect((await app.inject('/sync/pull')).statusCode).toBe(401); expect((await app.inject({ method: 'POST', url: '/sync/push', headers: { 'x-model-request': '1' }, payload: operation('delivery.create', deliveryData) })).statusCode).toBe(401); expect((await app.inject({ url: '/sync/pull', headers: { ...headers, 'x-model-user': randomUUID() } })).statusCode).toBe(401); expect((await app.inject({ method: 'POST', url: '/auth/logout', headers: { ...headers, origin: 'https://untrusted.example' } })).statusCode).toBe(403); expect((await app.inject({ method: 'POST', url: '/sync/push', headers: { cookie: headers.cookie! }, payload: operation('delivery.create', deliveryData) })).statusCode).toBe(403); }); it('provides a personal session, closes initial setup, and invalidates logout sessions', async () => { const current = (await get('/auth/session')).json(); expect(current.user.displayName).toBe(testAccount.displayName); const setup = await app.inject({ method: 'POST', url: '/local-admin/setup', headers, payload: { ...testAccount, username: 'another.admin', setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } }); expect(setup.statusCode).toBe(409); const login = await app.inject({ method: 'POST', url: '/local-admin/login', headers: { 'x-model-request': '1' }, payload: { username: testAccount.username, password: testAccount.password } }); expect(login.statusCode).toBe(200); expect(login.headers['set-cookie']).toContain('HttpOnly'); expect(login.headers['set-cookie']).toContain('SameSite=Strict'); const otherHeaders = { ...headers, cookie: login.cookies.map(c => `${c.name}=${c.value}`).join('; ') }; expect((await app.inject({ method: 'POST', url: '/auth/logout', headers: otherHeaders })).statusCode).toBe(200); expect((await app.inject({ url: '/sync/pull', headers: otherHeaders })).statusCode).toBe(401); const bad = await app.inject({ method: 'POST', url: '/local-admin/login', headers: { 'x-model-request': '1' }, payload: { username: testAccount.username, password: 'Incorrect-test-password' } }); expect(bad.statusCode).toBe(401); }); it('detects concurrent edits, applies explicit new-version retries, and keeps immutable values', async () => { const created = (await push(operation('delivery.create', deliveryData))).json().change.record; const edit = (notes: string, baseVersion: number, scheduledDate = '2026-09-07') => operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: created.id, baseVersion, kind: 'delivery.update', data: { customerId, addressId, scheduledDate, deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, notes } }); const a = edit('Device A', 1), b = edit('Device B', 1); expect((await push(a)).statusCode).toBe(200); const conflict = await push(b); expect(conflict.statusCode).toBe(409); expect(conflict.json().current.record).toMatchObject({ version: 2, notes: 'Device A' }); const retried = await push(edit('Device B', 2)); expect(retried.json().change.record).toMatchObject({ version: 3, notes: 'Device B', scheduledDate: '2026-09-07', deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, deliveryNumber: created.deliveryNumber, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, totalPriceAgorot: 1502, createdAt: created.createdAt }); expect((await pool.query("SELECT DATE_FORMAT(scheduled_date, '%Y-%m-%d') AS scheduled_date, delivery_slot, specific_time FROM deliveries WHERE id = ?", [created.id]))[0]).toMatchObject({ scheduled_date: '2026-09-07', delivery_slot: 'custom', specific_time: '14:35' }); const tampered = await app.inject({ method: 'POST', url: '/sync/push', headers, payload: { ...edit('Reprice', 3), data: { customerId, addressId, notes: 'Reprice', unitPriceAgorot: 1 } } }); expect(tampered.statusCode).toBe(400); const audit = await pool.query('SELECT actor_user_id FROM sync_operations WHERE operation_id = ?', [a.operationId]); expect(audit[0].actor_user_id).toBe(headers['x-model-user']); }); it('cancels idempotently, retains its number and prevents further edits', async () => { const created = (await push(operation('delivery.create', deliveryData))).json().change.record; const op = operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: created.id, baseVersion: 1, kind: 'delivery.cancel', data: { reason: 'Test cancellation' } }); const first = await push(op), replay = await push(op); expect(first.json().change.record).toMatchObject({ status: 'cancelled', version: 2, deliveryNumber: created.deliveryNumber, totalPriceAgorot: 1502 }); expect(replay.json().replayed).toBe(true); const edit = operationSchema.parse({ ...op, operationId: randomUUID(), baseVersion: 2, kind: 'delivery.update', data: { customerId, addressId, notes: 'Cannot reopen' } }); expect((await push(edit)).json().code).toBe('DELIVERY_CANCELLED'); const next = (await push(operation('delivery.create', deliveryData))).json().change.record; expect(next.deliveryNumber).toBeGreaterThan(created.deliveryNumber); }); it('updates and archives full customer profiles while preserving associated records', async () => { const profileId = randomUUID(), profileAddressId = randomUUID(); const details = { name: 'Profile test clinic', contactName: 'תום רוף', phone: '+972 50-123-4567', comments: 'Use the side entrance', deliveryWindows: { morning: { start: '08:00', end: '11:30' }, afternoon: { start: '13:00', end: '16:30' }, lateNight: null } }; const created = await push(operation('customer.create', details, profileId)); expect(created.json().change.record).toMatchObject({ ...details, version: 1, updatedAt: null }); const address = await push(operation('address.create', { customerId: profileId, label: 'Main', address: 'Main 7, Haifa', city: 'Haifa', street: 'Main', buildingNumber: '7' }, profileAddressId)); expect(address.json().change.record).toMatchObject({ city: 'Haifa', street: 'Main', buildingNumber: '7' }); const profileDelivery = (await push(operation('delivery.create', { ...deliveryData, customerId: profileId, addressId: profileAddressId }))).json().change.record; const edit = (name: string, baseVersion: number) => operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: profileId, baseVersion, kind: 'customer.update', data: { ...details, name } }); expect((await push(edit('Updated profile clinic', 1))).json().change.record).toMatchObject({ version: 2, name: 'Updated profile clinic', contactName: details.contactName }); const stale = await push(edit('Stale profile clinic', 1)); expect(stale.statusCode).toBe(409); expect(stale.json().current.record).toMatchObject({ version: 2, name: 'Updated profile clinic' }); const removed = operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: profileId, baseVersion: 2, kind: 'customer.remove', data: {} }); expect((await push(removed)).json().change.record).toMatchObject({ version: 3, name: 'Updated profile clinic' }); expect((await push(operation('delivery.create', { ...deliveryData, customerId: profileId, addressId: profileAddressId }))).json().code).toBe('ADDRESS_MISMATCH'); expect(Number((await pool.query('SELECT COUNT(*) AS count FROM deliveries WHERE id = ?', [profileDelivery.id]))[0].count)).toBe(1); expect(Number((await pool.query('SELECT COUNT(*) AS count FROM customer_addresses WHERE id = ?', [profileAddressId]))[0].count)).toBe(1); }); it('provides health, readiness, version and request IDs', async () => { expect((await app.inject('/health')).json()).toEqual({ status: 'ok' }); expect((await app.inject('/ready')).statusCode).toBe(200); const version = await app.inject('/version'); expect(version.json().syncProtocol).toBe(5); expect(version.headers['x-request-id']).toBeTruthy(); }); it('replays concurrent identical operations exactly once and rejects changed reuse', async () => { const op = operation('delivery.create', deliveryData); const responses = await Promise.all([push(op), push(op), push(op)]); expect(responses.map(r => r.statusCode)).toEqual([200, 200, 200]); const numbers = responses.map(r => r.json().change.record.deliveryNumber); expect(new Set(numbers).size).toBe(1); expect(responses.filter(r => r.json().replayed === false)).toHaveLength(1); const records = await pool.query('SELECT COUNT(*) AS count FROM deliveries WHERE id = ?', [op.entityId]); expect(Number(records[0].count)).toBe(1); const changes = await pool.query('SELECT COUNT(*) AS count FROM sync_changes WHERE entity_id = ?', [op.entityId]); expect(Number(changes[0].count)).toBe(1); const changed = { ...op, data: { ...deliveryData, notes: 'Changed request' } } as Operation; expect((await push(changed)).json().code).toBe('OPERATION_REUSED'); const versionConflict = { ...op, operationId: randomUUID() }; const conflict = await push(versionConflict); expect(conflict.statusCode).toBe(409); expect(conflict.json().current.record.id).toBe(op.entityId); }); it('allocates unique sequential numbers under concurrent creates', async () => { const replies = await Promise.all(Array.from({ length: 5 }, () => push(operation('delivery.create', deliveryData)))); expect(replies.every(r => r.statusCode === 200)).toBe(true); const numbers: number[] = replies.map(r => r.json().change.record.deliveryNumber).sort((a: number, b: number) => a - b); expect(new Set(numbers).size).toBe(5); expect(numbers.at(-1)! - numbers[0]!).toBe(4); expect(replies[0]!.json().change.record.totalPriceAgorot).toBe(1502); }); it('rejects another customer address and stale catalog without consuming a number', async () => { const before = await pool.query('SELECT next_delivery_number FROM sync_state'); const badAddress = await push(operation('delivery.create', { ...deliveryData, customerId: randomUUID() })); expect(badAddress.statusCode).toBe(422); expect(badAddress.json().code).toBe('ADDRESS_MISMATCH'); const stale = await push(operation('delivery.create', { ...deliveryData, deliveryTypeVersion: 99 })); expect(stale.json().code).toBe('CATALOG_CONFLICT'); const after = await pool.query('SELECT next_delivery_number FROM sync_state'); expect(after[0].next_delivery_number).toBe(before[0].next_delivery_number); }); it('paginates committed changes in ascending order and rejects invalid cursors', async () => { let cursor = '0'; const seen = new Set(); let pages = 0; while (true) { const response = await get(`/sync/pull?cursor=${cursor}&limit=2`); expect(response.statusCode).toBe(200); const page = response.json(); for (const entry of page.changes) { expect(BigInt(entry.sequence)).toBeGreaterThan(BigInt(cursor)); expect(seen.has(entry.sequence)).toBe(false); seen.add(entry.sequence); } cursor = page.nextCursor; pages++; if (!page.hasMore) break; if (pages > 1000) throw new Error('Pagination did not terminate'); } expect(pages).toBeGreaterThan(1); expect((await get('/sync/pull?cursor=-1')).statusCode).toBe(400); expect((await get('/sync/pull?cursor=18446744073709551615')).json().code).toBe('CURSOR_AHEAD'); }); it('validates create-only versions and refuses arbitrary request fields', async () => { const invalid = await app.inject({ method: 'POST', url: '/sync/push', headers, payload: { ...operation('delivery.create', deliveryData), baseVersion: 1 } }); expect(invalid.statusCode).toBe(400); const arbitrary = await app.inject({ method: 'POST', url: '/sync/push', headers, payload: { ...operation('delivery.create', deliveryData), admin: true } }); expect(arbitrary.statusCode).toBe(400); }); });