/** * The API against a real, migrated database (DATABASE_URL): the terminal protocol, the dashboard and the superadmin * page as they are used together. Wipes the database first. */ import { Test } from '@nestjs/testing'; import { NestExpressApplication } from '@nestjs/platform-express'; import { randomUUID } from 'crypto'; import request from 'supertest'; import { APP_VERSION_HEADER, SyncResponse } from '@kooltap/shared'; import { AppModule } from '../src/app.module'; import { configure } from '../src/main'; import { PrismaService } from '../src/prisma/prisma.service'; import { hashPassword } from '../src/auth/auth.service'; const API = '/api/v1'; const APP_VERSION = { [APP_VERSION_HEADER]: '2.0.0' }; let app: NestExpressApplication; let prisma: PrismaService; let venueId: string; let otherVenueId: string; beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = configure(module.createNestApplication({ logger: ['error'] })); await app.init(); prisma = app.get(PrismaService); await prisma.productImage.deleteMany(); await prisma.auditLog.deleteMany(); // Superadmins belong to no venue, so deleting the venues leaves them await prisma.user.deleteMany(); await prisma.venue.deleteMany(); venueId = (await prisma.venue.create({ data: { name: 'Café De Kroon' } })).id; otherVenueId = (await prisma.venue.create({ data: { name: 'Elsewhere' } })).id; const passwordHash = await hashPassword('Secret123!'); await prisma.user.createMany({ data: [ { venueId, email: 'admin@kroon.be', name: 'Owner', role: 'admin', passwordHash }, { venueId: null, email: 'root@kooltap.be', name: 'KoolTap', role: 'superadmin', passwordHash }, ], }); }); afterAll(async () => { await app.close(); }); const server = () => app.getHttpServer(); // Signed in once per user: logins are rate limited const agents = new Map>(); async function signIn(email: string) { let agent = agents.get(email); if (!agent) { agent = request.agent(server()); await agent.post(`${API}/auth/login`).send({ email, password: 'Secret123!' }).expect(200); agents.set(email, agent); } return agent; } describe('auth', () => { it('signs in with httpOnly cookies, refreshes and signs out', async () => { const login = await request(server()).post(`${API}/auth/login`).send({ email: ' ADMIN@kroon.be ', password: 'Secret123!' }).expect(200); expect(login.body).toMatchObject({ email: 'admin@kroon.be', role: 'admin', venueId, venueName: 'Café De Kroon' }); const cookies = ([] as string[]).concat(login.headers['set-cookie'] ?? []); expect(cookies.every((cookie) => /HttpOnly/i.test(cookie))).toBe(true); const agent = request.agent(server()); await agent.post(`${API}/auth/login`).send({ email: 'admin@kroon.be', password: 'Secret123!' }).expect(200); await agent.get(`${API}/auth/me`).expect(200); await agent.post(`${API}/auth/refresh`).expect(200); await agent.post(`${API}/auth/logout`).expect(200); await agent.post(`${API}/auth/refresh`).expect(401); }); it('refuses a wrong password and requests without a session', async () => { await request(server()).post(`${API}/auth/login`).send({ email: 'admin@kroon.be', password: 'nope' }).expect(401); await request(server()).get(`${API}/venues/${venueId}/tabs`).expect(401); }); it("keeps users out of other venues, but not a superadmin, who belongs to none", async () => { await (await signIn('admin@kroon.be')).get(`${API}/venues/${otherVenueId}/tabs`).expect(403); const root = await signIn('root@kooltap.be'); expect((await root.get(`${API}/auth/me`).expect(200)).body).toMatchObject({ role: 'superadmin', venueId: null, venueName: null }); await root.get(`${API}/venues/${otherVenueId}/tabs`).expect(200); // As the venue's admin await root.get(`${API}/venues/${otherVenueId}/users`).expect(200); }); }); describe('terminal and dashboard', () => { const productId = randomUUID(); const tabId = randomUUID(); const lineId = randomUUID(); let token: string; let sync: (body: object) => Promise; // In the past: a terminal clock ahead of the server is capped at its time let at = Date.now() - 60 * 60 * 1000; const event = (type: string, fields: object) => ({ id: randomUUID(), type, occurredAt: new Date((at += 1000)).toISOString(), ...fields }); it('pairs a terminal with a registration code, once, and refuses an outdated app', async () => { const admin = await signIn('admin@kroon.be'); const { body: { code } } = await admin.post(`${API}/venues/${venueId}/registration-codes`).expect(201); await request(server()).post(`${API}/terminal/register`).send({ registrationCode: code, name: 'Bar' }).expect(426); const registered = await request(server()).post(`${API}/terminal/register`).set(APP_VERSION) .send({ registrationCode: code.toLowerCase(), name: 'Bar' }).expect(201); expect(registered.body).toMatchObject({ venueId, venueName: 'Café De Kroon' }); await request(server()).post(`${API}/terminal/register`).set(APP_VERSION).send({ registrationCode: code, name: 'Bar 2' }).expect(401); // A 1.x app still on the old protocol is told to update, so it keeps its outbox for (const path of ['/devices/register', `/devices/${registered.body.deviceId}/sync`]) { const old = await request(server()).post(`${API}${path}`).set('x-app-version', '1.4.4+0').send({}).expect(426); expect(old.body.error.code).toBe('app_update_required'); } await request(server()).get(`${API}/venues/`).set('x-app-version', '1.4.4+0').expect(426); token = registered.body.token; sync = async (body) => (await request(server()).post(`${API}/terminal/sync`).set(APP_VERSION) .set('Authorization', `Bearer ${token}`).send(body).expect(200)).body; }); it('applies events once and returns the venue state', async () => { const events = [ event('product_created', { productId, product: { name: 'Cola', priceInCents: 250 } }), event('stock_set', { productId, quantity: 24 }), event('tab_opened', { tabId, customerName: 'Jan', email: 'Jan@Example.com' }), event('line_added', { tabId, customerName: 'Jan', lineId, orderId: 'o1', productId, productName: 'Cola', unitPriceInCents: 250, quantity: 3 }), event('line_removed', { tabId, lineId, orderId: 'o1', quantity: 1 }), { id: 'broken', type: 'tab_opened' }, ]; const first = await sync({ events }); expect(first.acknowledgedEventIds).toHaveLength(5); expect(first.rejectedEvents).toMatchObject([{ id: 'broken' }]); expect(first.tabs).toMatchObject([{ id: tabId, customerName: 'Jan', email: 'jan@example.com', lines: [{ id: lineId, quantity: 2 }] }]); expect(first.stock).toEqual([{ productId, stock: 22, countedAt: expect.any(String) }]); expect(first.catalog).toMatchObject([{ id: productId, name: 'Cola', archived: false }]); // Sent again, as a terminal does until it gets the acknowledgement: nothing changes, and the catalog isn't resent const again = await sync({ events, catalogVersion: first.catalogVersion, settlementsCursor: first.settlementsCursor }); expect(again.tabs).toEqual(first.tabs); expect(again.stock).toEqual(first.stock); expect(again.catalog).toBeNull(); expect(await prisma.tabUnit.count({ where: { tabId } })).toBe(3); }); it('settles on a terminal and feeds the settlement to every terminal', async () => { const settled = event('settled', { tabId, customerName: 'Jan', amountInCents: 250, paymentMethod: 'cash', items: [{ lineId, productId, productName: 'Cola', unitPriceInCents: 250, quantity: 1 }], }); const result = await sync({ events: [settled], settlementsCursor: '0' }); expect(result.tabs[0].lines).toMatchObject([{ quantity: 1 }]); expect(result.settlements).toMatchObject([{ id: settled.id, amountInCents: 250, lines: [{ quantity: 1, units: [{}, { removedAt: expect.any(String) }] }] }]); expect((await sync({ settlementsCursor: result.settlementsCursor })).settlements).toEqual([]); }); it('shows the tab on the dashboard and settles it there, refusing a stale version', async () => { const admin = await signIn('admin@kroon.be'); const [tab] = (await admin.get(`${API}/venues/${venueId}/tabs`).expect(200)).body; expect(tab).toMatchObject({ id: tabId, totalInCents: 250, itemCount: 1 }); await admin.post(`${API}/venues/${venueId}/tabs/${tabId}/settle`).send({ expectedVersion: tab.version - 1, paymentMethod: 'cash' }).expect(409); await admin.post(`${API}/venues/${venueId}/tabs/${tabId}/settle`).send({ expectedVersion: tab.version }).expect(400); const settled = await admin.post(`${API}/venues/${venueId}/tabs/${tabId}/settle`).send({ expectedVersion: tab.version, paymentMethod: 'payconiq' }).expect(200); expect(settled.body.amountInCents).toBe(250); await admin.post(`${API}/venues/${venueId}/tabs/${tabId}/settle`).send({ expectedVersion: tab.version, paymentMethod: 'payment_pending' }).expect(400); const history = (await admin.get(`${API}/venues/${venueId}/settlements?limit=10`).expect(200)).body; expect(history).toMatchObject({ total: 2, totalAmountInCents: 500 }); expect(history.settlements[0]).toMatchObject({ paymentMethod: 'payconiq', settledBy: 'Owner', onDashboard: true }); expect(history.settlements[1]).toMatchObject({ paymentMethod: 'cash', settledBy: 'Bar', items: [{ quantity: 1, removed: 1 }] }); }); it('opens a tab for a new customer on the next sync', async () => { const admin = await signIn('admin@kroon.be'); await admin.post(`${API}/venues/${venueId}/customers`).send({ name: ' Marie Peeters ', email: 'marie@example.com' }).expect(201); await admin.post(`${API}/venues/${venueId}/customers`).send({ name: 'marie peeters' }).expect(409); const result = await sync({}); expect(result.tabs.map((t) => t.customerName)).toContain('Marie Peeters'); // Jan was added by the terminal that opened his tab with an email expect((await admin.get(`${API}/venues/${venueId}/customers`).expect(200)).body).toMatchObject([ { name: 'Jan', email: 'jan@example.com' }, { name: 'Marie Peeters', tabOpenedAt: expect.any(String) }, ]); }); it('records stock movements and refuses adding to an untracked product', async () => { const admin = await signIn('admin@kroon.be'); const { body: chips } = await admin.post(`${API}/venues/${venueId}/stock/products`).send({ name: 'Chips', priceInCents: 200 }).expect(201); await admin.post(`${API}/venues/${venueId}/stock/movements`).send({ movements: [{ productId: chips.id, type: 'add', quantity: 5 }] }).expect(400); const { body } = await admin.post(`${API}/venues/${venueId}/stock/movements`).send({ movements: [{ productId: chips.id, type: 'count', quantity: 10 }, { productId: chips.id, type: 'add', quantity: -2, note: 'Broken' }], }).expect(201); expect(body.movements.map((m: { stockAfter: number }) => m.stockAfter).sort()).toEqual([10, 8]); const products = (await admin.get(`${API}/venues/${venueId}/stock/products`).expect(200)).body; expect(products.find((p: { id: string }) => p.id === chips.id)).toMatchObject({ stock: 8 }); await admin.post(`${API}/venues/${venueId}/stock/products`).send({ name: 'chips', priceInCents: 100 }).expect(409); }); it('unpairs the terminal, which then cannot sync', async () => { const admin = await signIn('admin@kroon.be'); const [device] = (await admin.get(`${API}/venues/${venueId}/devices`).expect(200)).body; await admin.delete(`${API}/venues/${venueId}/devices/${device.id}`).expect(200); await request(server()).post(`${API}/terminal/sync`).set(APP_VERSION).set('Authorization', `Bearer ${token}`).send({}).expect(401); }); }); describe('superadmin', () => { it('is the only one who reaches the admin page', async () => { await (await signIn('admin@kroon.be')).get(`${API}/admin/venues`).expect(403); const venues = (await (await signIn('root@kooltap.be')).get(`${API}/admin/venues`).expect(200)).body; expect(venues.map((v: { name: string }) => v.name)).toEqual(['Café De Kroon', 'Elsewhere']); }); it('resets the tabs, which brings the Free Drinks customer back', async () => { const root = await signIn('root@kooltap.be'); const dryRun = (await root.post(`${API}/admin/venues/${venueId}/reset`).send({ scopes: ['tabs'], dryRun: true }).expect(200)).body; expect(dryRun.counts.tabs).toBeGreaterThan(0); await root.post(`${API}/admin/venues/${venueId}/reset`).send({ scopes: ['tabs'] }).expect(200); expect(await prisma.tab.count({ where: { venueId } })).toBe(0); expect(await prisma.customer.findMany({ where: { venueId }, select: { name: true } })).toContainEqual({ name: 'Free Drinks' }); }); it('checks a venue and the system', async () => { const root = await signIn('root@kooltap.be'); const consistency = (await root.get(`${API}/admin/venues/${venueId}/consistency`).expect(200)).body; expect(consistency.findings.filter((f: { code: string }) => f.code === 'settlement_amount_mismatch')).toEqual([]); const health = (await root.get(`${API}/admin/health`).expect(200)).body; expect(health.migrations).toMatchObject({ applied: 1, failed: [] }); }); it('moves a user between a venue and above the venues', async () => { const root = await signIn('root@kooltap.be'); const { body: users } = await root.get(`${API}/admin/users`).expect(200); const owner = users.find((u: { email: string }) => u.email === 'admin@kroon.be'); await root.patch(`${API}/admin/users/${owner.id}/role`).send({ role: 'superadmin' }).expect(200); expect(await prisma.user.findUnique({ where: { id: owner.id } })).toMatchObject({ role: 'superadmin', venueId: null }); await root.patch(`${API}/admin/users/${owner.id}/role`).send({ role: 'admin' }).expect(400); await root.patch(`${API}/admin/users/${owner.id}/role`).send({ role: 'admin', venueId }).expect(200); expect(await prisma.user.findUnique({ where: { id: owner.id } })).toMatchObject({ role: 'admin', venueId }); }); it('deletes a venue with everything in it', async () => { const root = await signIn('root@kooltap.be'); await root.delete(`${API}/admin/venues/${otherVenueId}?confirmName=Wrong`).expect(400); await root.delete(`${API}/admin/venues/${otherVenueId}?confirmName=Elsewhere`).expect(200); expect(await prisma.venue.count({ where: { id: otherVenueId } })).toBe(0); }); });