import { v4 as uuidv4 } from 'uuid'; import { DevicesService } from './devices.service'; import { SyncDto } from './dto/sync.dto'; import { createFakePrisma, FakePrisma } from '../testing/fake-prisma'; import { StockService } from '../stock/stock.service'; type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date }; type SyncResult = Awaited>; // In the past: a time ahead of the server clock would be capped const at = (minutes: number) => new Date(Date.UTC(2026, 8, 24, 20, minutes)).toISOString(); function setup({ seeded = true } = {}) { const prisma = createFakePrisma(); prisma.venue.rows.push({ id: 'v1', tabsSeededAt: seeded ? new Date('2026-10-01') : null }); const tabletA: Device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() }; const tabletB: Device = { id: 'd2', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() }; prisma.device.rows.push({ ...tabletA }, { ...tabletB }); const realtime = { notify: jest.fn() }; const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any); const sync = (device: Device, body: Partial = {}) => service.sync(device, { deviceId: device.id, openTabs: [], ...body } as SyncDto); return { prisma, service, realtime, sync, tabletA, tabletB }; } /** Builds the events a terminal sends, with fresh ids. */ const events = { opened: (tabId: string, customerName: string, minutes = 0) => ( { id: uuidv4(), type: 'tab_opened', tabId, customerName, occurredAt: at(minutes), openedAt: at(minutes) }), renamed: (tabId: string, customerName: string, minutes = 0) => ( { id: uuidv4(), type: 'tab_renamed', tabId, customerName, occurredAt: at(minutes) }), deleted: (tabId: string, customerName: string, minutes = 0) => ( { id: uuidv4(), type: 'tab_deleted', tabId, customerName, occurredAt: at(minutes) }), line: (type: 'line_added' | 'line_removed', tabId: string, quantity: number, { minutes = 0, lineId = undefined as string | undefined, productId = 'cola', productName = 'Cola', price = 250, } = {}) => ({ id: uuidv4(), type, tabId, customerName: 'Alice', occurredAt: at(minutes), lineId, productId, productName, unitPriceInCents: price, quantity, }), settled: (tabId: string, quantity: number, minutes = 0) => ({ id: uuidv4(), type: 'settled', tabId, customerName: 'Alice', occurredAt: at(minutes), amountInCents: quantity * 250, paymentMethod: 'payconiq', items: [{ productId: 'cola', productName: 'Cola', quantity, unitPriceInCents: 250 }], }), product: (type: string, productId: string, product: Record | undefined, minutes = 0) => ( { id: uuidv4(), type, productId, ...(product ? { product } : {}), occurredAt: at(minutes) }), stockSet: (productId: string, quantity: number | null, minutes = 0) => ( { id: uuidv4(), type: 'stock_set', productId, quantity, occurredAt: at(minutes) }), }; const catalog = (stock: number | null = 0) => [ { id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock }, ]; function tab(result: SyncResult, tabId: string) { return result.tabs.find((t) => t.id === tabId); } function colaStock(result: SyncResult) { return result.stock.find((s) => s.productId === 'cola')?.stock; } describe('DevicesService.sync: the server holds the tabs', () => { it('shows a newly paired tablet the open tabs of the venue', async () => { const { sync, tabletA, tabletB } = setup(); await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] }); const result = await sync(tabletB); expect(result.tabs).toHaveLength(1); expect(result.tabs[0]).toMatchObject({ id: 't1', customerName: 'Alice', version: 2 }); expect(result.tabs[0].lines).toMatchObject([{ productId: 'cola', productName: 'Cola', quantity: 2 }]); }); it('gives units the ids the tablets give them when replaying', async () => { const { sync, tabletA } = setup(); const added = events.line('line_added', 't1', 2, { lineId: 'line-1' }); const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] }); expect(tab(result, 't1')!.lines[0]).toMatchObject({ id: 'line-1', units: [ { id: `${added.id}:0`, addedAt: at(0), removedAt: null }, { id: `${added.id}:1`, addedAt: at(0), removedAt: null }, ], }); }); it('adds what two tablets put on one tab', async () => { const { sync, tabletA, tabletB } = setup(); await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'line-1' })] }); // Tablet B replayed its drink onto the server's line, so it uses that line id await sync(tabletB, { events: [events.line('line_added', 't1', 1, { lineId: 'line-1', minutes: 1 })] }); const result = await sync(tabletA); expect(tab(result, 't1')!.lines).toMatchObject([{ id: 'line-1', quantity: 3 }]); }); it('applies an event once, also when it arrives again in a later sync', async () => { const { sync, tabletA, prisma } = setup(); const added = events.line('line_added', 't1', 1); await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] }); const result = await sync(tabletA, { events: [added, added] }); expect(result.acknowledgedEventIds).toEqual([added.id, added.id]); expect(tab(result, 't1')!.lines[0].quantity).toBe(1); expect(prisma.tabEvent.rows).toHaveLength(2); }); it('catches up with everything a tablet did offline, in order', async () => { const { sync, tabletA, tabletB } = setup(); await sync(tabletB, { events: [events.opened('bob', 'Bob')] }); await sync(tabletA, { events: [ events.opened('t1', 'Alice', 1), events.line('line_added', 't1', 3, { minutes: 2 }), events.line('line_removed', 't1', 1, { minutes: 3 }), events.renamed('t1', 'Alice V.', 4), events.deleted('bob', 'Bob', 5), ], }); const result = await sync(tabletB); expect(result.tabs.map((t) => t.customerName)).toEqual(['Alice V.']); const [line] = tab(result, 't1')!.lines; expect(line.quantity).toBe(2); // The latest drink was taken back expect(line.units.map((u) => u.removedAt)).toEqual([null, null, at(3)]); }); it('settles only what the tablet saw, leaving a drink added elsewhere on the tab', async () => { const { sync, tabletA, tabletB } = setup(); await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] }); await sync(tabletB, { events: [events.line('line_added', 't1', 1, { minutes: 5 })] }); // Tablet A was offline and settled the 2 Cola it knew about const result = await sync(tabletA, { events: [events.settled('t1', 2, 3)] }); expect(tab(result, 't1')!.lines).toMatchObject([{ quantity: 1, units: [{ addedAt: at(5) }] }]); expect(result.settlements).toHaveLength(1); expect(result.settlements[0]).toMatchObject({ tabId: 't1', paymentMethod: 'payconiq', amountInCents: 500 }); expect(result.settlements[0].lines).toMatchObject([{ productId: 'cola', quantity: 2 }]); expect(result.settlements[0].lines[0].units.map((u) => u.addedAt)).toEqual([at(0), at(0)]); }); it('brings a tab deleted elsewhere back with the drink that was put on it', async () => { const { sync, tabletA, tabletB } = setup(); await sync(tabletA, { events: [events.opened('bob', 'Bob')] }); await sync(tabletB, { events: [events.deleted('bob', 'Bob', 1)] }); const result = await sync(tabletA, { events: [{ ...events.line('line_added', 'bob', 1, { minutes: 2, productId: 'beer', productName: 'Beer', price: 400 }), customerName: 'Bob' }], }); expect(tab(result, 'bob')).toMatchObject({ customerName: 'Bob', lines: [{ productName: 'Beer', quantity: 1 }] }); }); it('matches events from older app versions by product name and price', async () => { const { sync, tabletA } = setup(); const { lineId, productId, ...old } = events.line('line_added', 't1', 2); await sync(tabletA, { events: [events.opened('t1', 'Alice'), old] }); const { lineId: _, productId: __, ...removed } = events.line('line_removed', 't1', 1, { minutes: 1 }); const result = await sync(tabletA, { events: [removed] }); expect(tab(result, 't1')!.lines).toMatchObject([{ productId: null, quantity: 1 }]); }); it('does not apply a rejected event, and still syncs the rest', async () => { const { sync, tabletA } = setup(); const invalid = { ...events.line('line_added', 't1', 1), quantity: 0 }; const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), invalid] }); expect(result.rejectedEvents).toEqual([{ id: invalid.id, reason: expect.any(String) }]); expect(tab(result, 't1')!.lines).toEqual([]); }); it('opens planned customers as tabs once, instead of on every tablet', async () => { const { sync, tabletA, tabletB, prisma } = setup(); prisma.plannedTab.rows.push( { id: 'p1', venueId: 'v1', customerName: 'Els', customerNameKey: 'els', deliveredAt: null, position: 0 }, { id: 'p2', venueId: 'v1', customerName: 'alice', customerNameKey: 'alice', deliveredAt: null, position: 1 }, ); await sync(tabletA, { events: [events.opened('t1', 'Alice')] }); const result = await sync(tabletB); expect(result.plannedTabNames).toEqual([]); expect(result.tabs.map((t) => t.customerName).sort()).toEqual(['Alice', 'Els']); expect(prisma.plannedTab.rows.every((p) => p.deliveredAt !== null)).toBe(true); }); }); describe('DevicesService.sync: taking over a tablet once', () => { it("takes over the tablet's tabs and stock without applying that request's events again", async () => { const { sync, tabletA, prisma } = setup({ seeded: false }); const result = await sync(tabletA, { openTabs: [{ id: 't1', customerName: 'Alice', version: 4, totalInCents: 750, itemCount: 3, items: [{ productName: 'Cola', quantity: 3, unitPriceInCents: 250 }], }], // Already in the tabs and stock above events: [events.line('line_added', 't1', 1)], products: catalog(45), }); expect(tab(result, 't1')).toMatchObject({ customerName: 'Alice', version: 4, lines: [{ productName: 'Cola', quantity: 3 }] }); expect(colaStock(result)).toBe(45); expect(prisma.venue.rows[0].tabsSeededAt).not.toBeNull(); const next = await sync(tabletA, { openTabs: [], events: [events.line('line_added', 't1', 1, { productId: 'cola', minutes: 1 })], products: catalog(44), }); expect(tab(next, 't1')!.lines[0].quantity).toBe(4); expect(colaStock(next)).toBe(44); }); }); describe('DevicesService.sync: settlements feed', () => { it("sends every tablet the venue's settlements once, after its cursor", async () => { const { sync, tabletA, tabletB } = setup(); const settled = events.settled('t1', 1, 2); await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled] }); const first = await sync(tabletB); expect(first.settlements.map((s) => s.id)).toEqual([settled.id]); const second = await sync(tabletB, { settlementsCursor: first.settlementsCursor }); expect(second.settlements).toEqual([]); expect(second.settlementsCursor).toBe(first.settlementsCursor); }); it('starts an empty feed after the latest settlement', async () => { const { sync, tabletA } = setup(); const result = await sync(tabletA); expect(result.settlements).toEqual([]); expect(result.settlementsCursor).toBe('0'); }); }); /** A product added on the dashboard, untracked. */ function addProduct(prisma: FakePrisma, productId = 'cola', fields: Record = {}) { prisma.product.rows.push({ venueId: 'v1', productId, name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: null, countedAt: null, lowStockThreshold: null, archivedAt: null, reportedAt: new Date(), ...fields, }); } describe('DevicesService.sync: the dashboard manages the catalog', () => { it('sends every product, archived ones marked, and ignores what a tablet reports', async () => { const { sync, tabletA, prisma } = setup(); addProduct(prisma, 'cola', { lowStockThreshold: 6 }); addProduct(prisma, 'old', { name: 'Old', category: null, archivedAt: new Date() }); const result = await sync(tabletA, { products: [ { id: 'cola', name: 'Renamed on the tablet', category: null, priceInCents: 999, stock: 3 }, { id: 'local', name: 'Seeded on the tablet', category: null, priceInCents: 100, stock: 24 }, ], }); // By category, products without one last (as Postgres sorts) expect(result.catalog).toEqual([ { id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, lowStockThreshold: 6, archived: false }, { id: 'old', name: 'Old', category: null, priceInCents: 250, lowStockThreshold: null, archived: true }, ]); expect(prisma.product.rows).toHaveLength(2); }); it("takes over the tablet's catalog when the venue is taken over", async () => { const { sync, tabletA } = setup({ seeded: false }); const result = await sync(tabletA, { products: catalog(12) }); expect(result.catalog).toMatchObject([{ id: 'cola', name: 'Cola', archived: false }]); expect(colaStock(result)).toBe(12); }); }); describe('DevicesService.sync: the server holds the stock', () => { async function trackedCola(sync: ReturnType['sync'], tablet: Device, stock = 48) { return sync(tablet, { events: [events.stockSet('cola', stock, -10)] }); } /** Every test here starts with Cola on the dashboard. */ function setupWithCola() { const venue = setup(); addProduct(venue.prisma); return venue; } it('ignores a count for a product the venue does not have', async () => { const { sync, tabletA } = setupWithCola(); const result = await sync(tabletA, { events: [events.stockSet('local', 24)] }); expect(result.stock).toEqual([{ productId: 'cola', stock: null, countedAt: null }]); }); it('counts the sales of every tablet once', async () => { const { sync, tabletA, tabletB } = setupWithCola(); await trackedCola(sync, tabletA); const sale = events.line('line_added', 't1', 2, { minutes: 1 }); await sync(tabletA, { events: [events.opened('t1', 'Alice'), sale] }); await sync(tabletA, { events: [sale] }); const result = await sync(tabletB, { events: [events.line('line_added', 't1', 2, { minutes: 2 })] }); expect(colaStock(result)).toBe(44); }); it('gives stock back for drinks taken off, but not for a settlement', async () => { const { sync, tabletA } = setupWithCola(); await trackedCola(sync, tabletA); const result = await sync(tabletA, { events: [ events.opened('t1', 'Alice'), events.line('line_added', 't1', 3, { minutes: 1 }), events.line('line_removed', 't1', 1, { minutes: 2 }), events.settled('t1', 2, 3), ], }); expect(colaStock(result)).toBe(46); }); it('keeps a count when sales from before it arrive later', async () => { const { sync, tabletA, tabletB } = setupWithCola(); await trackedCola(sync, tabletA); await sync(tabletB, { events: [events.stockSet('cola', 30, 60)] }); const result = await sync(tabletA, { events: [ events.opened('t1', 'Alice'), // Rung up offline before the count: already in it events.line('line_added', 't1', 2, { minutes: 50 }), // An older count from an offline tablet doesn't overwrite the newer one events.stockSet('cola', 70, 55), events.line('line_added', 't1', 3, { minutes: 70 }), ], }); expect(colaStock(result)).toBe(27); expect(result.stock[0].countedAt).toBe(at(60)); }); it('applies a pending dashboard adjustment once and no longer sends adjustments', async () => { const { sync, tabletA, tabletB, prisma } = setupWithCola(); await trackedCola(sync, tabletA); prisma.stockAdjustment.rows.push({ id: 'adj-1', venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24, status: 'pending', createdAt: new Date(), }); const first = await sync(tabletA); const second = await sync(tabletB); expect(first.stockAdjustments).toEqual([]); expect(colaStock(first)).toBe(72); expect(colaStock(second)).toBe(72); expect(prisma.stockAdjustment.rows[0]).toMatchObject({ status: 'applied', stockAfter: 72 }); }); it('records results from older tablets that applied an adjustment themselves', async () => { const { sync, tabletA, prisma } = setupWithCola(); await trackedCola(sync, tabletA); const id = uuidv4(); prisma.stockAdjustment.rows.push({ id, venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24, status: 'pending', createdAt: new Date(), }); const result = await sync(tabletA, { stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }] }); expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]); // Applied by the tablet, so not again by the server expect(colaStock(result)).toBe(48); }); }); describe('DevicesService.sync live update signals', () => { it('sends nothing when the sync changed nothing', async () => { const { sync, tabletA, realtime } = setup(); await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] }); realtime.notify.mockClear(); await sync(tabletA, { products: catalog() }); expect(realtime.notify).not.toHaveBeenCalled(); }); it('signals "tabs" and "stock" when they changed', async () => { const { sync, tabletA, realtime, prisma } = setup(); addProduct(prisma); await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.stockSet('cola', 5)] }); expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock'); }); it('signals "device" when a terminal comes back online', async () => { const { sync, realtime } = setup(); await sync({ id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date(Date.now() - 3_600_000) }); expect(realtime.notify).toHaveBeenCalledWith('v1', 'device'); }); }); describe('DevicesService.sync: website product changes reach the tablets', () => { const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' }; function setupWithWebsite() { const venue = setup(); const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any); return { ...venue, stock }; } it('sends a new price to every tablet in its next sync', async () => { const { sync, tabletA, tabletB, stock, prisma } = setupWithWebsite(); addProduct(prisma); await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin); const a = await sync(tabletA); const b = await sync(tabletB); for (const result of [a, b]) { expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', priceInCents: 270 })]); } }); it('keeps old drinks at their price and puts new ones on a new line', async () => { const { sync, tabletA, tabletB, stock, prisma } = setupWithWebsite(); addProduct(prisma); await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'old-line' })], }); await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin); // The tablet charges the new price on a line of its own const result = await sync(tabletB, { events: [events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 })], }); expect(tab(result, 't1')!.lines.map((l) => [l.id, l.unitPriceInCents, l.quantity])).toEqual([ ['old-line', 250, 2], ['new-line', 270, 1], ]); }); it('settles every line at its own price', async () => { const { sync, tabletA, prisma } = setupWithWebsite(); addProduct(prisma); const settled = { ...events.settled('t1', 0, 10), amountInCents: 2 * 250 + 270, items: [ { productId: 'cola', productName: 'Cola', quantity: 2, unitPriceInCents: 250 }, { productId: 'cola', productName: 'Cola', quantity: 1, unitPriceInCents: 270 }, ], }; const result = await sync(tabletA, { events: [ events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'old-line' }), events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 }), settled, ], }); expect(tab(result, 't1')!.lines).toEqual([]); expect(result.settlements[0].lines.map((l) => [l.unitPriceInCents, l.quantity])).toEqual([[250, 2], [270, 1]]); }); it('counts both price lines down from the one product stock', async () => { const { sync, tabletA, stock, prisma } = setupWithWebsite(); addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') }); await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] }); await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin); const result = await sync(tabletA, { events: [events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 })], }); expect(colaStock(result)).toBe(17); }); it('sends a product added on the website with its starting stock', async () => { const { sync, tabletA, stock } = setupWithWebsite(); const created = await stock.createProduct('v1', { name: 'Ice Tea', category: 'Frisdrank', priceInCents: 280, stock: 24 }, admin); const result = await sync(tabletA); expect(result.catalog).toEqual([expect.objectContaining({ id: created.productId, name: 'Ice Tea', priceInCents: 280 })]); expect(result.stock).toEqual([expect.objectContaining({ productId: created.productId, stock: 24 })]); }); it('marks an archived product, and still counts a sale from a tablet that had not synced yet', async () => { const { sync, tabletA, stock, prisma } = setupWithWebsite(); addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') }); await stock.updateProduct('v1', 'cola', { archived: true }, admin); const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1, { minutes: 1 })], }); expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', archived: true })]); expect(tab(result, 't1')!.lines).toMatchObject([{ productId: 'cola', quantity: 1 }]); expect(colaStock(result)).toBe(19); }); it('a renamed product keeps its id, so tablets update it instead of adding one', async () => { const { sync, tabletA, stock, prisma } = setupWithWebsite(); addProduct(prisma); await stock.updateProduct('v1', 'cola', { name: 'Coca-Cola' }, admin); const result = await sync(tabletA); expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', name: 'Coca-Cola' })]); }); }); describe('DevicesService.sync: products created and changed on a tablet', () => { const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' }; function setupWithWebsite() { const venue = setup(); const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any); return { ...venue, stock }; } const catalogEntry = (result: SyncResult, id: string) => result.catalog.find((p) => p.id === id); it('creates a product made offline, and sells and counts it in the same sync', async () => { const { sync, tabletA, tabletB } = setup(); await sync(tabletA, { events: [ events.product('product_created', 'iced', { name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3 }, 1), events.stockSet('iced', 12, 2), events.opened('t1', 'Alice', 3), events.line('line_added', 't1', 2, { minutes: 4, productId: 'iced', productName: 'Ice Coffee', price: 350 }), ], }); const result = await sync(tabletB); expect(catalogEntry(result, 'iced')).toEqual( { id: 'iced', name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3, archived: false, imageId: null }); expect(result.stock.find((s) => s.productId === 'iced')?.stock).toBe(10); }); it('keeps both a website price change and an offline rename of the same product', async () => { const { sync, tabletA, stock, prisma } = setupWithWebsite(); await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] }); await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin); // Renamed offline before the price change, synced after it const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 5)] }); expect(catalogEntry(result, 'cola')).toMatchObject({ name: 'Coca-Cola', priceInCents: 270 }); expect(prisma.product.rows[0].fieldUpdatedAt).toMatchObject({ name: at(5) }); }); it('lets the latest change of a field win, also when the older one arrives last', async () => { const { sync, tabletA, tabletB } = setup(); await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] }); await sync(tabletB, { events: [events.product('product_updated', 'cola', { priceInCents: 300 }, 20)] }); // Tablet A was offline: its older price change arrives later and loses const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 10)] }); expect(catalogEntry(result, 'cola')!.priceInCents).toBe(300); }); it('a website change made after a tablet change wins', async () => { const { sync, tabletA, stock } = setupWithWebsite(); await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] }); await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 1)] }); await stock.updateProduct('v1', 'cola', { priceInCents: 260 }, admin); const result = await sync(tabletA); expect(catalogEntry(result, 'cola')!.priceInCents).toBe(260); }); it('archives and restores a product from a tablet', async () => { const { sync, tabletA } = setup(); await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] }); const archived = await sync(tabletA, { events: [events.product('product_archived', 'cola', undefined, 1)] }); expect(catalogEntry(archived, 'cola')!.archived).toBe(true); const restored = await sync(tabletA, { events: [events.product('product_restored', 'cola', undefined, 2)] }); expect(catalogEntry(restored, 'cola')!.archived).toBe(false); }); it('only links a picture that was uploaded', async () => { const { sync, tabletA, prisma } = setup(); prisma.productImage.rows.push({ id: 'a'.repeat(64), contentType: 'image/webp', width: 512, height: 512, bytes: Buffer.from('x') }); await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] }); const unknown = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'b'.repeat(64) }, 1)] }); expect(catalogEntry(unknown, 'cola')!.imageId).toBeNull(); const known = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'a'.repeat(64) }, 2)] }); expect(catalogEntry(known, 'cola')!.imageId).toBe('a'.repeat(64)); const removed = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: null }, 3)] }); expect(catalogEntry(removed, 'cola')!.imageId).toBeNull(); }); it('accepts two products with the same name from two offline tablets', async () => { const { sync, tabletA, tabletB } = setup(); await sync(tabletA, { events: [events.product('product_created', 'cola-a', { name: 'Cola', priceInCents: 250 }, 0)] }); const result = await sync(tabletB, { events: [events.product('product_created', 'cola-b', { name: 'cola', priceInCents: 250 }, 0)] }); expect(result.catalog.map((p) => p.id).sort()).toEqual(['cola-a', 'cola-b']); }); it('acknowledges a change to a product the venue does not have, without creating it', async () => { const { sync, tabletA } = setup(); const change = events.product('product_updated', 'gone', { name: 'Gone' }, 0); const result = await sync(tabletA, { events: [change] }); expect(result.acknowledgedEventIds).toEqual([change.id]); expect(result.catalog).toEqual([]); }); it('rejects a new product without a price', async () => { const { sync, tabletA } = setup(); const incomplete = events.product('product_created', 'x', { name: 'X' }, 0); const result = await sync(tabletA, { events: [incomplete] }); expect(result.rejectedEvents).toEqual([{ id: incomplete.id, reason: 'a new product needs a name and a price' }]); }); it('applies a product event once, also when it is sent again', async () => { const { sync, tabletA, tabletB } = setup(); const created = events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0); const renamed = events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 1); await sync(tabletA, { events: [created, renamed] }); await sync(tabletB, { events: [events.product('product_updated', 'cola', { name: 'Cola Classic' }, 2)] }); // The old rename again, with its id: already applied, so it must not undo the newer name const result = await sync(tabletA, { events: [renamed] }); expect(catalogEntry(result, 'cola')!.name).toBe('Cola Classic'); }); });