feat: add image support to the products
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-21 12:45:19 +02:00
parent ec77dd0e27
commit 5c604e0851
38 changed files with 1241 additions and 77 deletions
@@ -44,6 +44,8 @@ const events = {
amountInCents: quantity * 250, paymentMethod: 'payconiq',
items: [{ productId: 'cola', productName: 'Cola', quantity, unitPriceInCents: 250 }],
}),
product: (type: string, productId: string, product: Record<string, unknown> | 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) }),
};
@@ -531,3 +533,134 @@ describe('DevicesService.sync: website product changes reach the tablets', () =>
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');
});
});