feat: move to V2
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-29 11:21:52 +01:00
parent 6342b8451e
commit 314717ba3a
244 changed files with 11615 additions and 15292 deletions
+262
View File
@@ -0,0 +1,262 @@
/**
* 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<NestExpressApplication>({ 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<string, ReturnType<typeof request.agent>>();
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<SyncResponse>;
// 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);
});
});