import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; 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'; import { drainDomainEvents } from './utils/domain-events'; interface Warning { code: string; severity: string; message: string; refs: { type: string; id: string }[]; } interface WithWarningsBody { id: string; warnings: Warning[]; } interface ExtractionPrefillBody { amountKg: number | null; honeyType: string | null; sources: { harvestId: string; date: string; netWeight: number | null }[]; warnings: Warning[]; } interface HoneyBatchPrefillBody { remainingKg: number | null; honeyType: string | null; waterContent: number | null; warnings: Warning[]; } /** * The honey chain against a real PostgreSQL: the prefill proposals, the * plausibility warnings of extraction and honey batch (never a 4xx), the * withdrawal check at the extraction stage and the webhook batch.created * that now comes from the event handler. */ describe('Honey chain (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; let apiaryId: string; let adminId: string; let staffId: string; let readerId: string; let roleId: string; let subscriptionId: string; const colonyIds: string[] = []; const harvestIds: string[] = []; const treatmentIds: string[] = []; const extractionIds: string[] = []; const honeyBatchIds: string[] = []; const server = () => app.getHttpServer(); const asAdmin = { Authorization: '' }; const asStaff = { Authorization: '' }; async function harvestWith( colonyId: string, day: string, netWeight: number | null, honeyType: string | null, ): Promise { const harvest = await prisma.harvest.create({ data: { apiaryId, userId: adminId, date: new Date(`${day}T00:00:00.000Z`), netWeight, honeyType, }, }); await prisma.harvestColony.create({ data: { harvestId: harvest.id, colonyId }, }); harvestIds.push(harvest.id); return harvest.id; } async function postExtraction( harvests: string[], amount?: number, ): Promise { const response = await request(server()) .post('/api/v1/extractions') .set(asAdmin) .send({ date: '2026-08-01T00:00:00.000Z', harvestIds: harvests, amount, }) .expect(201); const body = response.body as WithWarningsBody; extractionIds.push(body.id); return body; } async function postBatch( extractionId: string, amount: number, label: string, ): Promise { const response = await request(server()) .post('/api/v1/honey-batches') .set(asAdmin) .send({ batchNumber: `HC-${label}-${suffix}`, extractionId, amount, }) .expect(201); const body = response.body as WithWarningsBody; honeyBatchIds.push(body.id); return body; } /** Deliveries of this file's subscription that carry the given batch. */ async function deliveriesOfBatch(batchId: string) { const deliveries = await prisma.webhookDelivery.findMany({ where: { subscriptionId }, }); return deliveries.filter((delivery) => { const sent = JSON.parse(delivery.body) as { data?: { id?: string } }; return sent.data?.id === batchId; }); } const conflictRowsOf = (harvestId: string, colonyId: string) => prisma.notification.findMany({ where: { userId: readerId, dedupeKey: { startsWith: `withdrawal-conflict:${harvestId}:${colonyId}:`, }, }, }); beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const admin = await prisma.user.create({ data: { email: `honey-chain-admin-${suffix}@example.com`, passwordHash, name: 'Honey chain admin', role: { connect: { key: 'ADMIN' } }, }, }); adminId = admin.id; const staff = await prisma.user.create({ data: { email: `honey-chain-staff-${suffix}@example.com`, passwordHash, name: 'Honey chain staff', role: { connect: { key: 'STAFF' } }, }, }); staffId = staff.id; asAdmin.Authorization = `Bearer ${(await login(app, admin.email, password)).accessToken}`; asStaff.Authorization = `Bearer ${(await login(app, staff.email, password)).accessToken}`; const role = await prisma.role.create({ data: { key: `HC_READER_${suffix}`, name: 'Honigkette Leser', permissions: { create: ['harvests:read', 'treatments:read'].map((key) => ({ permission: { connect: { key } }, })), }, }, }); roleId = role.id; readerId = ( await prisma.user.create({ data: { email: `honey-chain-reader-${suffix}@example.com`, passwordHash, name: 'Honey chain reader', roleId, }, }) ).id; apiaryId = ( await prisma.apiary.create({ data: { name: `Honigkette ${suffix}` } }) ).id; subscriptionId = ( await prisma.webhookSubscription.create({ data: { url: 'https://hooks.example.com/honey-chain-e2e', events: ['batch.created'], secret: `honey-chain-${suffix}`, createdByUserId: adminId, }, }) ).id; }); afterAll(async () => { await prisma.notification.deleteMany({ where: { category: 'WITHDRAWAL', relatedId: { in: [...harvestIds, ...colonyIds] }, }, }); const rootIds = [...extractionIds, ...honeyBatchIds, ...harvestIds]; const roots = await prisma.domainEvent.findMany({ where: { aggregateId: { in: rootIds } }, select: { correlationId: true }, }); await prisma.domainEvent.deleteMany({ where: { OR: [ { aggregateId: { in: rootIds } }, { correlationId: { in: roots.map((root) => root.correlationId) } }, ], }, }); await prisma.webhookDelivery.deleteMany({ where: { subscriptionId } }); await prisma.webhookSubscription.deleteMany({ where: { id: subscriptionId }, }); await prisma.auditLog.deleteMany({ where: { OR: [ { userId: { in: [adminId, staffId, readerId] } }, { entityId: { in: [...extractionIds, ...honeyBatchIds, ...treatmentIds], }, }, ], }, }); await prisma.honeyBatch.deleteMany({ where: { id: { in: honeyBatchIds } }, }); await prisma.extractionHarvest.deleteMany({ where: { extractionId: { in: extractionIds } }, }); await prisma.extraction.deleteMany({ where: { id: { in: extractionIds } }, }); await prisma.harvestColony.deleteMany({ where: { harvestId: { in: harvestIds } }, }); await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } }); await prisma.treatment.deleteMany({ where: { id: { in: treatmentIds } } }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: [adminId, staffId, readerId] } }, }); await prisma.user.deleteMany({ where: { id: { in: [adminId, staffId, readerId] } }, }); await prisma.role.deleteMany({ where: { id: roleId } }); await app.close(); }); let colonyId: string; beforeAll(async () => { const colony = await prisma.colony.create({ data: { code: `HC-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyId = colony.id; colonyIds.push(colony.id); }); describe('prefill', () => { let harvestA: string; let harvestB: string; let harvestC: string; let harvestNoNet: string; beforeAll(async () => { harvestA = await harvestWith(colonyId, '2026-05-10', 20, 'Blütenhonig'); harvestB = await harvestWith(colonyId, '2026-05-12', 22.5, 'Blütenhonig'); harvestC = await harvestWith(colonyId, '2026-05-14', 5, 'Waldhonig'); harvestNoNet = await harvestWith(colonyId, '2026-05-16', null, null); }); it('prefill sums the net weights of the chosen harvests and keeps a shared honey type', async () => { const response = await request(server()) .get(`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestB}`) .set(asAdmin) .expect(200); expect(response.body as ExtractionPrefillBody).toEqual({ amountKg: 42.5, honeyType: 'Blütenhonig', sources: [ { harvestId: harvestA, date: '2026-05-10', netWeight: 20 }, { harvestId: harvestB, date: '2026-05-12', netWeight: 22.5 }, ], warnings: [], }); }); it('offers no honey type for different types and no amount when a net weight is missing', async () => { const mixed = await request(server()) .get(`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestC}`) .set(asAdmin) .expect(200); expect((mixed.body as ExtractionPrefillBody).honeyType).toBeNull(); expect((mixed.body as ExtractionPrefillBody).amountKg).toBe(25); const missing = await request(server()) .get( `/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestNoNet}`, ) .set(asAdmin) .expect(200); const body = missing.body as ExtractionPrefillBody; expect(body.amountKg).toBeNull(); expect(body.warnings.map((warning) => warning.code)).toEqual([ 'HARVEST_NET_MISSING', ]); }); it('rejects unknown, missing and too many harvest ids, and a user without extractions:create', async () => { await request(server()) .get(`/api/v1/extractions/prefill?harvestIds=${harvestA},nope`) .set(asAdmin) .expect(400); await request(server()) .get('/api/v1/extractions/prefill') .set(asAdmin) .expect(400); const tooMany = Array.from({ length: 51 }, (_, i) => `h${i}`).join(','); await request(server()) .get(`/api/v1/extractions/prefill?harvestIds=${tooMany}`) .set(asAdmin) .expect(400); await request(server()) .get(`/api/v1/extractions/prefill?harvestIds=${harvestA}`) .set(asStaff) .expect(403); }); it('batch prefill proposes the rest of the extraction with its honey type and water content', async () => { const extraction = await prisma.extraction.create({ data: { date: new Date('2026-08-01T00:00:00.000Z'), userId: adminId, amount: 42.5, waterContent: 17.2, }, }); extractionIds.push(extraction.id); await prisma.extractionHarvest.createMany({ data: [harvestA, harvestB].map((harvestId) => ({ extractionId: extraction.id, harvestId, })), }); await prisma.honeyBatch.create({ data: { batchNumber: `HC-PRE-1-${suffix}`, extractionId: extraction.id, amount: 20, initialAmount: 20, }, }); // A batch without a recorded amount does not count. await prisma.honeyBatch.create({ data: { batchNumber: `HC-PRE-2-${suffix}`, extractionId: extraction.id, }, }); honeyBatchIds.push( ...( await prisma.honeyBatch.findMany({ where: { extractionId: extraction.id }, select: { id: true }, }) ).map((batch) => batch.id), ); const response = await request(server()) .get(`/api/v1/honey-batches/prefill?extractionId=${extraction.id}`) .set(asAdmin) .expect(200); expect(response.body as HoneyBatchPrefillBody).toEqual({ remainingKg: 22.5, honeyType: 'Blütenhonig', waterContent: 17.2, warnings: [], }); await request(server()) .get('/api/v1/honey-batches/prefill?extractionId=nope') .set(asAdmin) .expect(404); await request(server()) .get('/api/v1/honey-batches/prefill') .set(asAdmin) .expect(400); await request(server()) .get(`/api/v1/honey-batches/prefill?extractionId=${extraction.id}`) .set(asStaff) .expect(403); }); }); describe('plausibility warnings', () => { let harvestA: string; let harvestNoNet: string; beforeAll(async () => { harvestA = await harvestWith(colonyId, '2026-06-10', 20, null); harvestNoNet = await harvestWith(colonyId, '2026-06-12', null, null); }); it('extraction and batch answers carry both plausibility warnings, never 4xx', async () => { // 30 kg out of 20 kg net. const extraction = await postExtraction([harvestA], 30); expect(extraction.warnings.map((warning) => warning.code)).toEqual([ 'EXTRACTION_EXCEEDS_HARVEST', ]); expect(extraction.warnings[0]).toMatchObject({ severity: 'WARNUNG', refs: [{ type: 'Harvest', id: harvestA }], }); // 25 + 10 kg of batches out of 30 kg. const first = await postBatch(extraction.id, 25, 'W1'); expect(first.warnings).toEqual([]); const second = await postBatch(extraction.id, 10, 'W2'); expect(second.warnings).toHaveLength(1); expect(second.warnings[0]).toMatchObject({ code: 'BATCHES_EXCEED_EXTRACTION', severity: 'WARNUNG', }); // The write happened all the same. expect(await prisma.honeyBatch.count({ where: { id: second.id } })).toBe( 1, ); const patched = await request(server()) .patch(`/api/v1/honey-batches/${second.id}`) .set(asAdmin) .send({ honeyType: 'Blütenhonig' }) .expect(200); expect( (patched.body as WithWarningsBody).warnings.map((w) => w.code), ).toEqual(['BATCHES_EXCEED_EXTRACTION']); const corrected = await request(server()) .patch(`/api/v1/extractions/${extraction.id}`) .set(asAdmin) .send({ amount: 20 }) .expect(200); expect( (corrected.body as WithWarningsBody).warnings.map((w) => w.code), ).toEqual([]); const noNet = await request(server()) .patch(`/api/v1/extractions/${extraction.id}`) .set(asAdmin) .send({ harvestIds: [harvestA, harvestNoNet], amount: 500 }) .expect(200); expect( (noNet.body as WithWarningsBody).warnings.map((w) => w.code), ).toEqual(['HARVEST_NET_MISSING']); }); it('records the events with audit entries on create and update', async () => { const own = await postExtraction([harvestA], 5); const id = own.id; await request(server()) .patch(`/api/v1/extractions/${id}`) .set(asAdmin) .send({ amount: 6 }) .expect(200); await request(server()) .patch(`/api/v1/extractions/${id}`) .set(asAdmin) .send({ harvestIds: [harvestA, harvestNoNet] }) .expect(200); const events = await prisma.domainEvent.findMany({ where: { aggregateType: 'Extraction', aggregateId: id }, orderBy: { occurredAt: 'asc' }, }); expect(events.map((event) => event.type)).toEqual([ 'ExtractionRecorded', 'ExtractionCorrected', 'ExtractionCorrected', ]); expect(events[2].payload).toEqual({ harvestIds: expect.arrayContaining([ harvestA, harvestNoNet, ]) as string[], }); expect( await prisma.auditLog.count({ where: { entityType: 'Extraction', entityId: id }, }), ).toBe(3); }); }); describe('withdrawal at the extraction stage', () => { it('an extraction of a harvest inside a withdrawal window warns but notifies no second time', async () => { const colony = await prisma.colony.create({ data: { code: `HC-W-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyIds.push(colony.id); // 2026-07-10 + 20 days: harvestable again from 2026-07-30. const treatment = await prisma.treatment.create({ data: { colonyId: colony.id, userId: adminId, date: new Date('2026-07-10T00:00:00.000Z'), product: 'Oxuvar', withdrawalPeriodDays: 20, }, }); treatmentIds.push(treatment.id); const harvestResponse = await request(server()) .post('/api/v1/harvests') .set(asAdmin) .send({ apiaryId, date: '2026-07-20T00:00:00.000Z', colonyIds: [colony.id], netWeight: 12, }) .expect(201); const harvestId = (harvestResponse.body as WithWarningsBody).id; harvestIds.push(harvestId); await drainDomainEvents(app, [harvestId]); expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1); const extraction = await postExtraction([harvestId], 10); expect(extraction.warnings).toHaveLength(1); expect(extraction.warnings[0]).toMatchObject({ code: 'WITHDRAWAL_ACTIVE', severity: 'WARNUNG', }); expect(extraction.warnings[0].refs).toEqual( expect.arrayContaining([ { type: 'Colony', id: colony.id }, { type: 'Treatment', id: treatment.id }, { type: 'Harvest', id: harvestId }, ]), ); await drainDomainEvents(app, [extraction.id]); await drainDomainEvents(app, [extraction.id]); expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1); }); it('the prefill already warns about a harvest inside a withdrawal window', async () => { const colony = await prisma.colony.create({ data: { code: `HC-P-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyIds.push(colony.id); const treatment = await prisma.treatment.create({ data: { colonyId: colony.id, userId: adminId, date: new Date('2026-07-10T00:00:00.000Z'), product: 'Oxuvar', withdrawalPeriodDays: 20, }, }); treatmentIds.push(treatment.id); const harvestId = await harvestWith(colony.id, '2026-07-20', 12, null); const response = await request(server()) .get(`/api/v1/extractions/prefill?harvestIds=${harvestId}`) .set(asAdmin) .expect(200); const body = response.body as ExtractionPrefillBody; expect(body.warnings.map((warning) => warning.code)).toEqual([ 'WITHDRAWAL_ACTIVE', ]); // Proposals only: nothing was written or notified. expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(0); }); it('an extraction notifies a conflict the harvest did not (harvest entered without an event)', async () => { const colony = await prisma.colony.create({ data: { code: `HC-X-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyIds.push(colony.id); const treatment = await prisma.treatment.create({ data: { colonyId: colony.id, userId: adminId, date: new Date('2026-07-10T00:00:00.000Z'), product: 'Oxuvar', withdrawalPeriodDays: 20, }, }); treatmentIds.push(treatment.id); const harvestId = await harvestWith(colony.id, '2026-07-20', 12, null); expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(0); const extraction = await postExtraction([harvestId], 10); await drainDomainEvents(app, [extraction.id]); await drainDomainEvents(app, [extraction.id]); const rows = await conflictRowsOf(harvestId, colony.id); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ category: 'WITHDRAWAL', relatedType: 'Harvest', relatedId: harvestId, }); }); }); describe('webhook batch.created', () => { let batchId: string; it('a new honey batch delivers batch.created through the handler', async () => { const harvestId = await harvestWith(colonyId, '2026-09-01', 8, null); const extraction = await postExtraction([harvestId], 8); const batch = await postBatch(extraction.id, 8, 'WH'); batchId = batch.id; // The request itself queued nothing. expect(await deliveriesOfBatch(batchId)).toHaveLength(0); const events = await prisma.domainEvent.findMany({ where: { aggregateType: 'HoneyBatch', aggregateId: batchId }, }); expect(events.map((event) => event.type)).toEqual(['HoneyBatchRecorded']); expect(events[0].payload).toEqual({ extractionId: extraction.id }); await drainDomainEvents(app, [batchId]); await drainDomainEvents(app, [batchId]); const deliveries = await deliveriesOfBatch(batchId); expect(deliveries).toHaveLength(1); expect(deliveries[0].event).toBe('batch.created'); }); it('the batch.created payload has the same shape as before (honeyBatchInclude)', async () => { const [delivery] = await deliveriesOfBatch(batchId); const sent = JSON.parse(delivery.body) as { event: string; data: Record; }; const response = await request(server()) .get(`/api/v1/honey-batches/${batchId}`) .set(asAdmin) .expect(200); expect(sent.event).toBe('batch.created'); expect(sent.data).toEqual(response.body); expect(Object.keys(sent.data).sort()).toEqual( expect.arrayContaining([ 'measurements', 'storageContainer', 'extraction', 'batchNumber', ]), ); }); it('a correction records HoneyBatchCorrected and sends no second webhook', async () => { await request(server()) .patch(`/api/v1/honey-batches/${batchId}`) .set(asAdmin) .send({ honeyType: 'Sommertracht' }) .expect(200); await drainDomainEvents(app, [batchId]); const events = await prisma.domainEvent.findMany({ where: { aggregateType: 'HoneyBatch', aggregateId: batchId }, orderBy: { occurredAt: 'asc' }, }); expect(events.map((event) => event.type)).toEqual([ 'HoneyBatchRecorded', 'HoneyBatchCorrected', ]); expect(await deliveriesOfBatch(batchId)).toHaveLength(1); }); }); });