fix: alot
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-19 21:41:07 +02:00
parent df90cf8a18
commit fa38ac7617
70 changed files with 1389 additions and 1023 deletions
+35 -6
View File
@@ -1,12 +1,12 @@
import { Prisma } from '@prisma/client';
import { DevicesService } from './devices.service';
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number };
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown };
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
const tx = {
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn() },
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn() },
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() },
};
@@ -27,13 +27,13 @@ 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 });
describe('DevicesService.sync planned customers', () => {
it('marks planned customers delivered once the device reports a tab with their name', async () => {
it('marks planned customers delivered once the device reports a tab with their name, ignoring case', async () => {
const { service, tx } = createService();
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', 'Piet')] });
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', ' piet Peeters')] });
expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({
where: { venueId: 'v1', deliveredAt: null, customerName: { in: ['Jan', 'Piet'] } },
where: { venueId: 'v1', deliveredAt: null, customerNameKey: { in: ['jan', 'piet peeters'] } },
data: { deliveredAt: expect.any(Date) },
});
});
@@ -125,3 +125,32 @@ describe('DevicesService.sync tab lines', () => {
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();
});
});
+45 -16
View File
@@ -10,7 +10,8 @@ import { AuditService } from '../audit/audit.service';
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
import { Prisma } from '@prisma/client';
import { OpenTabDto, SyncDto } from './dto/sync.dto';
import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
import { customerNameKey } from '../planned-tabs/customer-name';
import { AckCommandDto } from './dto/ack-command.dto';
@Injectable()
@@ -36,22 +37,28 @@ export class DevicesService {
// Devices sync often, so dashboards are only told about what actually changed
const previous = await this.prisma.openTabSnapshot.findMany({
where: { deviceId: device.id },
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true },
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true, items: true },
});
const previousById = new Map(previous.map((s) => [s.tabId, s]));
const tabsChanged =
previous.length !== openTabs.length ||
openTabs.some((t) => {
const p = previousById.get(t.id);
return !p || p.customerName !== t.customerName || p.version !== t.version
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount;
});
let plannedDelivered = 0;
// Ownership changes are detected up front so audit logging stays outside the transaction
const takenOver = await this.prisma.tabOwnership.findMany({
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
});
const takenOverIds = new Set(takenOver.map((o) => o.tabId));
// Only new or changed tabs are written; unchanged ones just get their report time bumped
const changedTabs = openTabs.filter((t) => {
const p = previousById.get(t.id);
return !p || takenOverIds.has(t.id) || p.customerName !== t.customerName || p.version !== t.version
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount
|| !sameItems(p.items as StoredItem[] | null, t.items);
});
const changedIds = new Set(changedTabs.map((t) => t.id));
const unchangedIds = openTabIds.filter((id) => !changedIds.has(id));
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
let plannedDelivered = 0;
for (const ownership of takenOver) {
await this.auditService.log(
device.venueId,
@@ -71,7 +78,18 @@ export class DevicesService {
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
});
for (const tab of openTabs) {
if (unchangedIds.length > 0) {
await tx.openTabSnapshot.updateMany({
where: { deviceId: device.id, tabId: { in: unchangedIds } },
data: { reportedAt: now },
});
await tx.tabOwnership.updateMany({
where: { venueId: device.venueId, deviceId: device.id, tabId: { in: unchangedIds } },
data: { lastReportedAt: now },
});
}
for (const tab of changedTabs) {
await tx.openTabSnapshot.upsert({
where: {
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
@@ -111,14 +129,12 @@ export class DevicesService {
});
}
// A planned customer counts as delivered once the device reports a tab with that exact name
// (the terminal matches names exactly when creating tabs)
if (openTabs.length > 0) {
({ count: plannedDelivered } = await tx.plannedTab.updateMany({
where: {
venueId: device.venueId,
deliveredAt: null,
customerName: { in: openTabs.map((t) => t.customerName) },
customerNameKey: { in: openTabs.map((t) => customerNameKey(t.customerName)) },
},
data: { deliveredAt: now },
}));
@@ -279,8 +295,21 @@ export class DevicesService {
}
}
type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
if (!stored || !reported) return !stored && !reported;
return stored.length === reported.length && stored.every((s, i) =>
s.productName === reported[i].productName
&& s.quantity === reported[i].quantity
&& s.unitPriceInCents === reported[i].unitPriceInCents);
}
function storedItems(items: OpenTabItemDto[]): StoredItem[] {
return items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
}
/** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */
function snapshotItems(tab: OpenTabDto) {
if (!tab.items) return Prisma.JsonNull;
return tab.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
return tab.items ? storedItems(tab.items) : Prisma.JsonNull;
}