import { Prisma } from '@prisma/client'; import { parseTabEvents, tabEventRow } from './tab-events'; const now = new Date('2026-10-24T22:00:00.000Z'); const lineAdded = { id: '6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e', type: 'line_added', tabId: 'tab-1', customerName: 'Jan', occurredAt: '2026-10-24T21:41:03.120Z', productName: 'Cola', unitPriceInCents: 250, quantity: 2, }; const settled = { id: '7a2d3e4f-5b6c-4d7e-8f9a-0b1c2d3e4f5a', type: 'settled', tabId: 'tab-1', customerName: 'Jan', occurredAt: '2026-10-24T21:55:00.000Z', amountInCents: 500, paymentMethod: 'payconiq', items: [{ productName: 'Cola', quantity: 2, unitPriceInCents: 250 }], }; describe('parseTabEvents', () => { it('accepts valid line and settlement events', () => { const { accepted, rejected } = parseTabEvents([lineAdded, settled]); expect(accepted.map((e) => e.id)).toEqual([lineAdded.id, settled.id]); expect(rejected).toEqual([]); }); it('accepts tab and stock events, and a stock count of zero', () => { const base = { occurredAt: '2026-10-24T21:00:00.000Z' }; const { accepted, rejected } = parseTabEvents([ { ...base, id: '1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', type: 'tab_opened', tabId: 't1', customerName: 'Jan', openedAt: base.occurredAt }, { ...base, id: '2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f', type: 'tab_renamed', tabId: 't1', customerName: 'Jan P.' }, { ...base, id: '3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f7a', type: 'stock_set', productId: '12', quantity: 0 }, { ...base, id: '4e5f6a7b-8c9d-4e0f-9a2b-3c4d5e6f7a8b', type: 'stock_set', productId: '12', quantity: null }, ]); expect(rejected).toEqual([]); expect(accepted.map((e) => e.type)).toEqual(['tab_opened', 'tab_renamed', 'stock_set', 'stock_set']); }); it('rejects a stock count without a product or below zero, and a tab event without a tab', () => { const base = { occurredAt: '2026-10-24T21:00:00.000Z' }; const { accepted, rejected } = parseTabEvents([ { ...base, id: '5f6a7b8c-9d0e-4f1a-8b3c-4d5e6f7a8b9c', type: 'stock_set', quantity: 3 }, { ...base, id: '6a7b8c9d-0e1f-4a2b-9c4d-5e6f7a8b9c0d', type: 'stock_set', productId: '12', quantity: -1 }, { ...base, id: '7b8c9d0e-1f2a-4b3c-8d5e-6f7a8b9c0d1e', type: 'tab_opened', customerName: 'Jan' }, ]); expect(accepted).toEqual([]); expect(rejected).toHaveLength(3); }); it('stores a stock count without a tab', () => { const [event] = parseTabEvents([ { id: '8c9d0e1f-2a3b-4c4d-9e6f-7a8b9c0d1e2f', type: 'stock_set', productId: '12', quantity: 30, occurredAt: '2026-10-24T21:00:00.000Z' }, ]).accepted; expect(tabEventRow(event, 'v1', 'd1', now)).toMatchObject({ type: 'stock_set', tabId: null, productId: '12', stockSetTo: 30 }); }); it('rejects invalid events one by one, keeping their id', () => { const { accepted, rejected } = parseTabEvents([ lineAdded, { ...lineAdded, id: '8b3e4f5a-6c7d-4e8f-9a0b-1c2d3e4f5a6b', quantity: 0 }, { ...settled, id: '9c4f5a6b-7d8e-4f9a-0b1c-2d3e4f5a6b7c', paymentMethod: 'bitcoin' }, { ...lineAdded, id: 'not-a-uuid' }, 'nonsense', ]); expect(accepted.map((e) => e.id)).toEqual([lineAdded.id]); expect(rejected.map((r) => r.id)).toEqual([ '8b3e4f5a-6c7d-4e8f-9a0b-1c2d3e4f5a6b', '9c4f5a6b-7d8e-4f9a-0b1c-2d3e4f5a6b7c', 'not-a-uuid', null, ]); expect(rejected[0].reason).toMatch(/quantity/); }); it('ignores fields it does not know, so newer app versions keep working', () => { const { accepted } = parseTabEvents([{ ...lineAdded, discountInCents: 50 }]); expect(accepted).toHaveLength(1); expect(accepted[0]).not.toHaveProperty('discountInCents'); }); it('rejects line events without a product', () => { const { productName, ...withoutProduct } = lineAdded; expect(parseTabEvents([withoutProduct]).rejected).toHaveLength(1); }); }); describe('tabEventRow', () => { it('stores removed lines as a negative quantity', () => { const [event] = parseTabEvents([{ ...lineAdded, type: 'line_removed', quantity: 3 }]).accepted; expect(tabEventRow(event, 'v1', 'd1', now)).toMatchObject({ venueId: 'v1', deviceId: 'd1', type: 'line_removed', productName: 'Cola', unitPriceInCents: 250, quantityDelta: -3, occurredAt: new Date(lineAdded.occurredAt), }); }); it('stores a settlement with its amount, payment method and lines', () => { const [event] = parseTabEvents([settled]).accepted; const row = tabEventRow(event, 'v1', 'd1', now); expect(row).toMatchObject({ type: 'settled', amountInCents: 500, paymentMethod: 'payconiq', commandId: null }); expect(row.items).toEqual([{ productId: null, productName: 'Cola', quantity: 2, unitPriceInCents: 250 }]); expect(row.quantityDelta).toBeUndefined(); }); it('stores JSON null for a settlement without lines', () => { const { items, ...withoutItems } = settled; const [event] = parseTabEvents([withoutItems]).accepted; expect(tabEventRow(event, 'v1', 'd1', now).items).toBe(Prisma.JsonNull); }); it('uses the receive time when the terminal clock runs ahead', () => { const [event] = parseTabEvents([{ ...lineAdded, occurredAt: '2026-10-25T03:00:00.000Z' }]).accepted; expect(tabEventRow(event, 'v1', 'd1', now).occurredAt).toEqual(now); }); });