This commit is contained in:
@@ -1,293 +1,379 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { SyncDto } from './dto/sync.dto';
|
||||
import { createFakePrisma, FakePrisma } from '../testing/fake-prisma';
|
||||
|
||||
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown };
|
||||
type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date };
|
||||
type SyncResult = Awaited<ReturnType<DevicesService['sync']>>;
|
||||
|
||||
type StoredProduct = { productId: string; name: string; category: string | null; priceInCents: number; stock: number | null };
|
||||
// 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 createService({
|
||||
previous = [] as Snapshot[], delivered = 0, expired = 0, products = [] as StoredProduct[], pendingStock = [] as unknown[],
|
||||
recordedResults = 1,
|
||||
} = {}) {
|
||||
const tx = {
|
||||
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
|
||||
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
|
||||
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
|
||||
device: { update: jest.fn() },
|
||||
tabEvent: { createMany: jest.fn() },
|
||||
product: { deleteMany: jest.fn(), updateMany: jest.fn(), upsert: jest.fn() },
|
||||
stockAdjustment: { updateMany: jest.fn().mockResolvedValue({ count: recordedResults }) },
|
||||
};
|
||||
const prisma = {
|
||||
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
|
||||
tabOwnership: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
$transaction: jest.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||
remoteCommand: { updateMany: jest.fn().mockResolvedValue({ count: expired }), findMany: jest.fn().mockResolvedValue([]) },
|
||||
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Els' }]) },
|
||||
product: { findMany: jest.fn().mockResolvedValue(products) },
|
||||
stockAdjustment: { findMany: jest.fn().mockResolvedValue(pendingStock) },
|
||||
};
|
||||
const audit = { log: jest.fn() };
|
||||
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, audit as any, realtime as any);
|
||||
return { service, prisma, tx, realtime };
|
||||
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any);
|
||||
|
||||
const sync = (device: Device, body: Partial<SyncDto> = {}) =>
|
||||
service.sync(device, { deviceId: device.id, openTabs: [], ...body } as SyncDto);
|
||||
|
||||
return { prisma, service, realtime, sync, tabletA, tabletB };
|
||||
}
|
||||
|
||||
const device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
|
||||
const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 });
|
||||
/** 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 }],
|
||||
}),
|
||||
stockSet: (productId: string, quantity: number | null, minutes = 0) => (
|
||||
{ id: uuidv4(), type: 'stock_set', productId, quantity, occurredAt: at(minutes) }),
|
||||
};
|
||||
|
||||
describe('DevicesService.sync planned customers', () => {
|
||||
it('marks planned customers delivered once the device reports a tab with their name, ignoring case', async () => {
|
||||
const { service, tx } = createService();
|
||||
const catalog = (stock: number | null = 0) => [
|
||||
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock },
|
||||
];
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', ' piet Peeters')] });
|
||||
function tab(result: SyncResult, tabId: string) {
|
||||
return result.tabs.find((t) => t.id === tabId);
|
||||
}
|
||||
|
||||
expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({
|
||||
where: { venueId: 'v1', deliveredAt: null, customerNameKey: { in: ['jan', 'piet peeters'] } },
|
||||
data: { deliveredAt: expect.any(Date) },
|
||||
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('only sends customers that have not been delivered yet', async () => {
|
||||
const { service, prisma } = createService();
|
||||
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' })] });
|
||||
|
||||
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||
// 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(prisma.plannedTab.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { venueId: 'v1', deliveredAt: null } }),
|
||||
);
|
||||
expect(result.plannedTabNames).toEqual(['Els']);
|
||||
expect(tab(result, 't1')!.lines).toMatchObject([{ id: 'line-1', quantity: 3 }]);
|
||||
});
|
||||
|
||||
it('does not mark anything delivered when the device reports no tabs', async () => {
|
||||
const { service, tx } = createService();
|
||||
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] });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||
const result = await sync(tabletA, { events: [added, added] });
|
||||
|
||||
expect(tx.plannedTab.updateMany).not.toHaveBeenCalled();
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync: the server holds the stock', () => {
|
||||
async function trackedCola(sync: ReturnType<typeof setup>['sync'], tablet: Device, stock = 48) {
|
||||
return sync(tablet, { products: catalog(), events: [events.stockSet('cola', stock, -10)] });
|
||||
}
|
||||
|
||||
it('starts a new product untracked and ignores the stock in the catalog', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
|
||||
const result = await sync(tabletA, { products: catalog(99) });
|
||||
|
||||
expect(result.stock).toEqual([{ productId: 'cola', stock: null, countedAt: null }]);
|
||||
});
|
||||
|
||||
it('counts the sales of every tablet once', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
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 } = setup();
|
||||
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 } = setup();
|
||||
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 } = setup();
|
||||
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 } = setup();
|
||||
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);
|
||||
});
|
||||
|
||||
it('never removes a product another tablet does not know', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
await trackedCola(sync, tabletA);
|
||||
|
||||
const result = await sync(tabletB, { products: [{ id: 'water', name: 'Water', category: null, priceInCents: 200, stock: 3 }] });
|
||||
|
||||
expect(result.stock.map((s) => s.productId).sort()).toEqual(['cola', 'water']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync live update signals', () => {
|
||||
const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 };
|
||||
const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 };
|
||||
|
||||
it('sends nothing when the sync changed nothing', async () => {
|
||||
const { service, realtime } = createService({ previous: [janSnapshot] });
|
||||
const { sync, tabletA, realtime } = setup();
|
||||
await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] });
|
||||
realtime.notify.mockClear();
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
|
||||
await sync(tabletA, { products: catalog() });
|
||||
|
||||
expect(realtime.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signals "tabs" when a tab changed', async () => {
|
||||
const { service, realtime } = createService({ previous: [janSnapshot] });
|
||||
it('signals "tabs" and "stock" when they changed', async () => {
|
||||
const { sync, tabletA, realtime } = setup();
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, version: 3, totalInCents: 900, itemCount: 2 }] });
|
||||
await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] });
|
||||
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs');
|
||||
});
|
||||
|
||||
it('signals "tabs" when a tab disappeared', async () => {
|
||||
const { service, realtime } = createService({ previous: [janSnapshot] });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs');
|
||||
});
|
||||
|
||||
it('signals delivered customers and expired commands', async () => {
|
||||
const { service, realtime } = createService({ previous: [janSnapshot], delivered: 1, expired: 2 });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
|
||||
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'planned-tabs', 'commands');
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock');
|
||||
});
|
||||
|
||||
it('signals "device" when a terminal comes back online', async () => {
|
||||
const { service, realtime } = createService({ previous: [janSnapshot] });
|
||||
const { sync, realtime } = setup();
|
||||
|
||||
await service.sync({ ...device, lastSeenAt: new Date(Date.now() - 10 * 60 * 1000) }, { deviceId: 'd1', openTabs: [jan] });
|
||||
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 tab lines', () => {
|
||||
const base = { id: 't1', customerName: 'Bram', version: 4, totalInCents: 2500, itemCount: 10 };
|
||||
|
||||
it('stores the lines a terminal reports', async () => {
|
||||
const { service, tx } = createService();
|
||||
const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }];
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...base, items }] });
|
||||
|
||||
const { create, update } = tx.openTabSnapshot.upsert.mock.calls[0][0];
|
||||
expect(create.items).toEqual(items);
|
||||
expect(update.items).toEqual(items);
|
||||
});
|
||||
|
||||
it('stores no lines for app versions that do not report them', async () => {
|
||||
const { service, tx } = createService();
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [base] });
|
||||
|
||||
expect(tx.openTabSnapshot.upsert.mock.calls[0][0].update.items).toBe(Prisma.JsonNull);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync writes', () => {
|
||||
const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }];
|
||||
const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items };
|
||||
// JSONB doesn't keep key order
|
||||
const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items: [{ quantity: 10, productName: 'Cola', unitPriceInCents: 250 }] };
|
||||
|
||||
it('only bumps the report time of unchanged tabs', async () => {
|
||||
const { service, tx } = createService({ previous: [janSnapshot] });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
|
||||
|
||||
expect(tx.openTabSnapshot.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.tabOwnership.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.openTabSnapshot.updateMany).toHaveBeenCalledWith({
|
||||
where: { deviceId: 'd1', tabId: { in: ['t1'] } },
|
||||
data: { reportedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a tab whose lines changed', async () => {
|
||||
const { service, tx } = createService({ previous: [janSnapshot] });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, items: [{ ...items[0], quantity: 11 }] }] });
|
||||
|
||||
expect(tx.openTabSnapshot.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(tx.openTabSnapshot.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync tab events', () => {
|
||||
const event = {
|
||||
id: '6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e',
|
||||
type: 'line_added',
|
||||
tabId: 't1',
|
||||
customerName: 'Jan',
|
||||
occurredAt: new Date().toISOString(),
|
||||
productName: 'Cola',
|
||||
unitPriceInCents: 250,
|
||||
quantity: 2,
|
||||
};
|
||||
|
||||
it('stores events once and acknowledges them', async () => {
|
||||
const { service, tx } = createService();
|
||||
|
||||
const result = await service.sync(device, { deviceId: 'd1', openTabs: [], events: [event] });
|
||||
|
||||
expect(tx.tabEvent.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({ id: event.id, venueId: 'v1', deviceId: 'd1', quantityDelta: 2 })],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(result.acknowledgedEventIds).toEqual([event.id]);
|
||||
expect(result.rejectedEvents).toEqual([]);
|
||||
});
|
||||
|
||||
it('still syncs when an event is invalid, and reports it', async () => {
|
||||
const { service, tx } = createService();
|
||||
|
||||
const result = await service.sync(device, { deviceId: 'd1', openTabs: [], events: [{ ...event, quantity: -1 }] });
|
||||
|
||||
expect(tx.tabEvent.createMany).not.toHaveBeenCalled();
|
||||
expect(tx.device.update).toHaveBeenCalled();
|
||||
expect(result.acknowledgedEventIds).toEqual([]);
|
||||
expect(result.rejectedEvents).toEqual([{ id: event.id, reason: expect.stringMatching(/quantity/) }]);
|
||||
});
|
||||
|
||||
it('works for terminals that send no events', async () => {
|
||||
const { service, tx } = createService();
|
||||
|
||||
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||
|
||||
expect(tx.tabEvent.createMany).not.toHaveBeenCalled();
|
||||
expect(result.acknowledgedEventIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync stock', () => {
|
||||
const cola = { id: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
|
||||
const storedCola = { productId: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
|
||||
|
||||
it('leaves products alone when the terminal does not report them', async () => {
|
||||
const { service, prisma, tx } = createService({ products: [storedCola] });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||
|
||||
expect(prisma.product.findMany).not.toHaveBeenCalled();
|
||||
expect(tx.product.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only writes changed products, removes missing ones and signals "stock"', async () => {
|
||||
const storedWater = { productId: 'p2', name: 'Water', category: null, priceInCents: 200, stock: null };
|
||||
const { service, tx, realtime } = createService({ products: [storedCola, storedWater] });
|
||||
|
||||
await service.sync(device, {
|
||||
deviceId: 'd1',
|
||||
openTabs: [],
|
||||
products: [{ ...cola, stock: 46 }, { id: 'p3', name: ' Bier ', priceInCents: 300, stock: null }],
|
||||
});
|
||||
|
||||
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1', 'p3'] } } });
|
||||
expect(tx.product.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(tx.product.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
create: expect.objectContaining({ productId: 'p3', name: 'Bier', category: null, stock: null }),
|
||||
}));
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
|
||||
});
|
||||
|
||||
it('does not signal anything for an unchanged catalog', async () => {
|
||||
const { service, tx, realtime } = createService({ products: [storedCola], recordedResults: 0 });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola] });
|
||||
|
||||
expect(tx.product.upsert).not.toHaveBeenCalled();
|
||||
expect(realtime.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips an invalid product without failing the sync', async () => {
|
||||
const { service, tx } = createService();
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola, { id: 'p9', name: 'Broken', priceInCents: -1, stock: 3 }] });
|
||||
|
||||
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1'] } } });
|
||||
});
|
||||
|
||||
it('records stock adjustment results and acknowledges them', async () => {
|
||||
const { service, tx, realtime } = createService();
|
||||
const id = '5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c';
|
||||
|
||||
const result = await service.sync(device, {
|
||||
deviceId: 'd1',
|
||||
openTabs: [],
|
||||
stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }, { id: 'nope', status: 'applied' }],
|
||||
});
|
||||
|
||||
expect(tx.stockAdjustment.updateMany).toHaveBeenCalledWith({
|
||||
where: { id, venueId: 'v1', status: { in: ['pending', 'cancelled'] } },
|
||||
data: { status: 'applied', stockAfter: 72, result: null, processedAt: expect.any(Date) },
|
||||
});
|
||||
expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]);
|
||||
expect(result.rejectedStockAdjustmentResults.map((r) => r.id)).toEqual(['nope']);
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
|
||||
});
|
||||
|
||||
it('sends the pending stock adjustments of the venue', async () => {
|
||||
const pending = [{ id: 'a1', productId: 'p1', type: 'add', quantity: 24, note: null, requestedBy: 'Bram', createdAt: new Date() }];
|
||||
const { service, prisma } = createService({ pendingStock: pending });
|
||||
|
||||
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||
|
||||
expect(prisma.stockAdjustment.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { venueId: 'v1', status: 'pending' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}));
|
||||
expect(result.stockAdjustments).toEqual(pending);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user