import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { randomUUID } from 'node:crypto'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; interface Warning { code: string; severity: string; message: string; refs: { type: string; id: string }[]; } interface BottlingBody { id: string; jarSize: number; bestBeforeDate: string; warnings: Warning[]; } interface ConfirmationBody { statusCode: number; code: string; waterContent: number; message: string; warnings: Warning[]; } interface PrefillBody { jarSize: number | null; jarSizeSource: string | null; bestBeforeDate: string | null; shelfLifeMonths: number | null; waterContent: number | null; warnings: Warning[]; } describe('Bottling quality (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const stamp = Date.now(); const email = `bottling-quality-e2e-${stamp}@example.com`; const password = 'SuperSecret123!'; let userId: string; let token: string; let staffId: string; let staffToken: string; let extractionId: string; const honeyBatchIds: string[] = []; const productIds: string[] = []; const bottlingIds: string[] = []; const api = () => request(app.getHttpServer()); const auth = (req: request.Test) => req.set('Authorization', `Bearer ${token}`); async function newBatch( waterContent: number | null, honeyType: string | null = null, ): Promise { const batch = await prisma.honeyBatch.create({ data: { batchNumber: `BQ-${stamp}-${honeyBatchIds.length}`, extractionId, amount: 25, initialAmount: 25, waterContent, honeyType, }, }); honeyBatchIds.push(batch.id); return batch.id; } async function newProduct(data: { packagingSize?: number; shelfLifeMonths?: number; }): Promise { const product = await prisma.product.create({ data: { name: `Qualität ${stamp}-${productIds.length}`, kind: 'BOTTLED_HONEY', ...data, }, }); productIds.push(product.id); return product.id; } /** What a write would leave behind for the batch: movements, events, audit. */ async function writtenCounts(honeyBatchId: string) { return { movements: await prisma.batchMovement.count({ where: { honeyBatchId } }), events: await prisma.domainEvent.count({ where: { aggregateType: 'BottlingBatch', payload: { path: ['honeyBatchId'], equals: honeyBatchId }, }, }), audits: await prisma.auditLog.count({ where: { userId, entityType: 'BottlingBatch', action: 'CREATE' }, }), }; } function create(body: Record, key?: string): request.Test { const req = auth(api().post('/api/v1/bottling-batches')); if (key) { req.set('Idempotency-Key', key); } return req.send({ date: '2026-01-31', jarCount: 2, lotNumber: `BQ-L-${stamp}-${randomUUID()}`, ...body, }); } function remember(response: { body: unknown }): BottlingBody { const body = response.body as BottlingBody; bottlingIds.push(body.id); return body; } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const user = await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: 'Quality', role: { connect: { key: 'BEEKEEPER' } }, }, }); userId = user.id; token = (await login(app, email, password)).accessToken; const extraction = await prisma.extraction.create({ data: { date: new Date(), userId }, }); extractionId = extraction.id; // STAFF has no bottling:create. const staff = await prisma.user.create({ data: { email: `bottling-quality-staff-${stamp}@example.com`, passwordHash: await bcrypt.hash(password, 12), name: 'Staff', role: { connect: { key: 'STAFF' } }, }, }); staffId = staff.id; staffToken = (await login(app, staff.email, password)).accessToken; }); afterAll(async () => { await prisma.domainEvent.deleteMany({ where: { aggregateType: 'BottlingBatch', aggregateId: { in: bottlingIds }, }, }); await prisma.bottlingBatch.deleteMany({ where: { id: { in: bottlingIds } }, }); await prisma.batchMovement.deleteMany({ where: { honeyBatchId: { in: honeyBatchIds } }, }); await prisma.honeyMeasurement.deleteMany({ where: { honeyBatchId: { in: honeyBatchIds } }, }); await prisma.honeyBatch.deleteMany({ where: { id: { in: honeyBatchIds } }, }); await prisma.extraction.deleteMany({ where: { id: extractionId } }); await prisma.product.deleteMany({ where: { id: { in: productIds } } }); await prisma.idempotencyRecord.deleteMany({ where: { userId } }); const userIds = [userId, staffId]; await prisma.refreshToken.deleteMany({ where: { userId: { in: userIds } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await app.close(); }); it('17 % -> 201 without a quality warning', async () => { const honeyBatchId = await newBatch(17); const response = await create({ honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31', }).expect(201); expect(remember(response).warnings).toEqual([]); }); it('19 % -> 201 with WATER_CONTENT_HIGH', async () => { const honeyBatchId = await newBatch(19); const response = await create({ honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31', }).expect(201); const warnings = remember(response).warnings; expect(warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_HIGH']); expect(warnings[0].severity).toBe('WARNUNG'); }); it('no water content at all -> 201 with the hint WATER_CONTENT_MISSING', async () => { const honeyBatchId = await newBatch(null); const response = await create({ honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31', }).expect(201); expect(remember(response).warnings.map((w) => w.code)).toEqual([ 'WATER_CONTENT_MISSING', ]); }); it('20.5 % -> 409 CONFIRMATION_REQUIRED; confirmed retry with the same key -> 201', async () => { const honeyBatchId = await newBatch(20.5); const key = randomUUID(); const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' }; const countsBefore = await writtenCounts(honeyBatchId); const refused = await create(body, key).expect(409); const refusal = refused.body as ConfirmationBody; expect(refusal.code).toBe('CONFIRMATION_REQUIRED'); expect(refusal.warnings.map((w) => [w.code, w.severity])).toEqual([ ['WATER_CONTENT_ABOVE_LEGAL', 'BESTAETIGEN'], ]); expect(refusal.warnings[0].refs).toEqual([ { type: 'HoneyBatch', id: honeyBatchId }, ]); expect(refusal.waterContent).toBe(20.5); // Nothing was written: no bottling, the rest is untouched. expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe( 0, ); expect( ( await prisma.honeyBatch.findUniqueOrThrow({ where: { id: honeyBatchId }, }) ).amount, ).toBe(25); expect(await writtenCounts(honeyBatchId)).toEqual(countsBefore); const confirmed = await create( { ...body, acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'], acknowledgedWaterContent: 20.5, }, key, ).expect(201); expect(remember(confirmed).warnings.map((w) => w.code)).toEqual([ 'WATER_CONTENT_ABOVE_LEGAL', ]); expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe( 1, ); // A third call with the same key replays the stored 201. const replay = await create( { ...body, acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'], acknowledgedWaterContent: 20.5, }, key, ).expect(201); expect((replay.body as BottlingBody).id).toBe( (confirmed.body as BottlingBody).id, ); expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe( 1, ); }); it('a confirmation is bound to the value: another value gets a fresh 409', async () => { const honeyBatchId = await newBatch(21); const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31', acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'], }; // Confirmed for 20.5 %, but the batch holds 21 %. const stale = await create({ ...body, acknowledgedWaterContent: 20.5, }).expect(409); expect((stale.body as ConfirmationBody).waterContent).toBe(21); // The code alone is no confirmation either. await create(body).expect(409); await create({ ...body, acknowledgedWaterContent: 101 }).expect(400); expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe( 0, ); remember( await create({ ...body, acknowledgedWaterContent: 21 }).expect(201), ); }); it('exactly 20.0 % only warns: 201 with WATER_CONTENT_HIGH', async () => { const honeyBatchId = await newBatch(20); const response = await create({ honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31', }).expect(201); expect(remember(response).warnings.map((w) => w.code)).toEqual([ 'WATER_CONTENT_HIGH', ]); }); it('an impossible day is a 400, never a 500', async () => { const honeyBatchId = await newBatch(17); await create({ honeyBatchId, jarSize: 500, date: '2026-02-30', bestBeforeDate: '2027-01-31', }).expect(400); await create({ honeyBatchId, jarSize: 500, bestBeforeDate: '2027-02-30', }).expect(400); await auth(api().get('/api/v1/bottling-batches/prefill')) .query({ honeyBatchId, date: '2026-02-30' }) .expect(400); }); it('a newer low measurement lifts the stop; a newer high one sets it', async () => { const honeyBatchId = await newBatch(21); const userRow = { honeyBatchId, userId }; await prisma.honeyMeasurement.create({ data: { ...userRow, waterContent: 16.8, measuredAt: new Date('2026-08-01'), }, }); const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' }; remember(await create(body).expect(201)); await prisma.honeyMeasurement.create({ data: { ...userRow, waterContent: 22, measuredAt: new Date('2026-09-01'), }, }); const refused = await create(body).expect(409); expect((refused.body as ConfirmationBody).code).toBe( 'CONFIRMATION_REQUIRED', ); }); it('rejects an unknown acknowledged code and more than one', async () => { const honeyBatchId = await newBatch(17); const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' }; await create({ ...body, acknowledgedWarnings: ['SOMETHING'] }).expect(400); await create({ ...body, acknowledgedWarnings: [ 'WATER_CONTENT_ABOVE_LEGAL', 'WATER_CONTENT_ABOVE_LEGAL', ], }).expect(400); }); it('a correction reports quality findings only when it changes the jar size or honey batch', async () => { const honeyBatchId = await newBatch(17); const created = remember( await create({ honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31', }).expect(201), ); await prisma.honeyBatch.update({ where: { id: honeyBatchId }, data: { waterContent: 21 }, }); const lotOnly = await auth( api().patch(`/api/v1/bottling-batches/${created.id}`), ) .send({ lotNumber: `BQ-L-${stamp}-${randomUUID()}` }) .expect(200); expect((lotOnly.body as BottlingBody).warnings).toEqual([]); const patched = await auth( api().patch(`/api/v1/bottling-batches/${created.id}`), ) .send({ jarSize: 250 }) .expect(200); expect((patched.body as BottlingBody).warnings.map((w) => w.code)).toEqual([ 'WATER_CONTENT_ABOVE_LEGAL', ]); }); it('a honey measurement above the limit answers warnings and is never refused', async () => { const honeyBatchId = await newBatch(17); const response = await auth( api().post(`/api/v1/honey-batches/${honeyBatchId}/measurements`), ) .send({ waterContent: 21, measuredAt: '2026-10-01T09:00:00.000Z' }) .expect(201); const warnings = (response.body as { warnings: Warning[] }).warnings; expect(warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_ABOVE_LEGAL']); expect( await prisma.honeyMeasurement.count({ where: { honeyBatchId } }), ).toBe(1); }); it('best before comes from shelfLifeMonths and a different jar size gives a hint', async () => { const honeyBatchId = await newBatch(17); const productId = await newProduct({ packagingSize: 500, shelfLifeMonths: 1, }); const fromArticle = remember( await create({ honeyBatchId, productId }).expect(201), ); expect(fromArticle.jarSize).toBe(500); // 2026-01-31 plus one month, cut at the month end. expect(fromArticle.bestBeforeDate.slice(0, 10)).toBe('2026-02-28'); expect(fromArticle.warnings).toEqual([]); const otherJar = remember( await create({ honeyBatchId, productId, jarSize: 250 }).expect(201), ); expect(otherJar.jarSize).toBe(250); expect(otherJar.warnings.map((w) => w.code)).toEqual(['JAR_SIZE_DIFFERS']); const sentDate = remember( await create({ honeyBatchId, productId, bestBeforeDate: '2030-05-05', }).expect(201), ); expect(sentDate.bestBeforeDate.slice(0, 10)).toBe('2030-05-05'); }); it('refuses with a clear text when jar size and best before are unknown', async () => { const honeyBatchId = await newBatch(17); const noShelfLife = await newProduct({ packagingSize: 500 }); const none = await create({ honeyBatchId }).expect(400); expect((none.body as { message: string }).message).toBe( 'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.', ); const noDate = await create({ honeyBatchId, productId: noShelfLife, }).expect(400); expect((noDate.body as { message: string }).message).toBe( 'Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.', ); }); describe('GET /bottling-batches/prefill', () => { it('prefill returns jar size and best before from the product', async () => { const honeyBatchId = await newBatch(19); const productId = await newProduct({ packagingSize: 250, shelfLifeMonths: 24, }); const response = await auth(api().get('/api/v1/bottling-batches/prefill')) .query({ honeyBatchId, productId, date: '2026-05-10' }) .expect(200); const prefill = response.body as PrefillBody; expect(prefill).toMatchObject({ jarSize: 250, jarSizeSource: 'PRODUCT', bestBeforeDate: '2028-05-10', shelfLifeMonths: 24, waterContent: 19, }); expect(prefill.warnings.map((w) => w.code)).toEqual([ 'WATER_CONTENT_HIGH', ]); // Read only. expect( await prisma.bottlingBatch.count({ where: { honeyBatchId } }), ).toBe(0); }); it('without an article it still reports the water content', async () => { const honeyBatchId = await newBatch(null); const response = await auth(api().get('/api/v1/bottling-batches/prefill')) .query({ honeyBatchId }) .expect(200); const prefill = response.body as PrefillBody; expect(prefill.jarSize).toBeNull(); expect(prefill.bestBeforeDate).toBeNull(); expect(prefill.warnings.map((w) => w.code)).toEqual([ 'WATER_CONTENT_MISSING', ]); }); it('needs bottling:create: 403 for STAFF', async () => { const honeyBatchId = await newBatch(17); await api() .get('/api/v1/bottling-batches/prefill') .set('Authorization', `Bearer ${staffToken}`) .query({ honeyBatchId }) .expect(403); }); it('refuses an article that is not bottled honey with 400', async () => { const honeyBatchId = await newBatch(17); const product = await prisma.product.create({ data: { name: `Kerze ${stamp}`, kind: 'MANUFACTURED' }, }); productIds.push(product.id); await auth(api().get('/api/v1/bottling-batches/prefill')) .query({ honeyBatchId, productId: product.id }) .expect(400); }); it('answers 404 for unknown ids and 400 without a honey batch', async () => { await auth(api().get('/api/v1/bottling-batches/prefill')) .query({ honeyBatchId: 'missing' }) .expect(404); await auth(api().get('/api/v1/bottling-batches/prefill')).expect(400); }); }); });