From 40ba5f956430fe9071efae0570d7fcf6c4112ccb Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Wed, 21 Oct 2026 00:24:06 +0200 Subject: [PATCH] fix: sync issue --- apps/api/src/devices/devices.service.spec.ts | 115 +++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts index 876e4b9..6bb0925 100644 --- a/apps/api/src/devices/devices.service.spec.ts +++ b/apps/api/src/devices/devices.service.spec.ts @@ -2,6 +2,7 @@ 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>; @@ -416,3 +417,117 @@ describe('DevicesService.sync live update signals', () => { 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' })]); + }); +});