import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { buildTreatmentLog, treatmentLogGaps, } from '../src/export/treatment-log'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { pdfText } from './utils/pdf-text'; import { login } from './utils/api'; describe('Export (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const beekeeperEmail = `export-e2e-beekeeper-${Date.now()}@example.com`; const password = 'SuperSecret123!'; let beekeeperId: string; let beekeeperToken: string; let apiaryId: string; let colonyId: string; let harvestId: string; let extractionId: string; let honeyBatchId: string; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const beekeeper = await prisma.user.create({ data: { email: beekeeperEmail, passwordHash, name: 'Beekeeper', role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; const apiary = await prisma.apiary.create({ data: { name: `Stand ${Date.now()}` }, }); apiaryId = apiary.id; const colony = await prisma.colony.create({ data: { code: `V-${Date.now()}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyId = colony.id; const harvest = await prisma.harvest.create({ data: { apiaryId, date: new Date(), userId: beekeeperId }, }); harvestId = harvest.id; await prisma.harvestColony.create({ data: { harvestId, colonyId } }); const extraction = await prisma.extraction.create({ data: { date: new Date(), userId: beekeeperId }, }); extractionId = extraction.id; await prisma.extractionHarvest.create({ data: { extractionId, harvestId }, }); const honeyBatch = await prisma.honeyBatch.create({ data: { batchNumber: `HC-${Date.now()}`, extractionId, amount: 20 }, }); honeyBatchId = honeyBatch.id; }); afterAll(async () => { await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } }); await prisma.extractionHarvest.deleteMany({ where: { extractionId } }); await prisma.extraction.deleteMany({ where: { id: extractionId } }); await prisma.harvestColony.deleteMany({ where: { harvestId } }); await prisma.harvest.deleteMany({ where: { id: harvestId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); await prisma.user.deleteMany({ where: { id: beekeeperId } }); await app.close(); }); it('exports colonies as CSV', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/export/colonies.csv') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); expect(response.headers['content-type']).toContain('text/csv'); expect(response.text.split('\n')[0]).toContain('code'); expect(response.text).toContain(colonyId); }); it('exports honey batches as CSV', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/export/honey-batches.csv') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); expect(response.text).toContain(honeyBatchId); }); it('exports colonies as JSON', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/export/colonies.json') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); const body = response.body as { id: string }[]; expect(body.some((colony) => colony.id === colonyId)).toBe(true); }); it('rejects an unsupported JSON entity with 400', () => { return request(app.getHttpServer()) .get('/api/v1/export/bogus.json') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(400); }); it('exports the honey batch traceability report as PDF', async () => { const response = await request(app.getHttpServer()) .get( `/api/v1/export/honey-batch-traceability.pdf?honeyBatchId=${honeyBatchId}`, ) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); expect(response.headers['content-type']).toContain('application/pdf'); const pdfBody = response.body as Buffer; expect(pdfBody.subarray(0, 4).toString('latin1')).toBe('%PDF'); }); it('rejects the traceability report without honeyBatchId with 400', () => { return request(app.getHttpServer()) .get('/api/v1/export/honey-batch-traceability.pdf') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(400); }); it('rejects the colony annual report without a colonyId', () => { return request(app.getHttpServer()) .get('/api/v1/export/colony-annual-report.pdf') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(400); }); it('rejects the colony annual report for an unknown colony', () => { return request(app.getHttpServer()) .get('/api/v1/export/colony-annual-report.pdf?colonyId=does-not-exist') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(404); }); it('renders a colony annual report PDF', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/export/colony-annual-report.pdf?colonyId=${colonyId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); expect(response.headers['content-type']).toContain('application/pdf'); expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe( '%PDF', ); }); it('renders the record card (Stockkarte) for the whole history and for one year', async () => { for (const query of [ `colonyId=${colonyId}`, `colonyId=${colonyId}&year=2026`, ]) { const response = await request(app.getHttpServer()) .get(`/api/v1/export/colony-record-card.pdf?${query}`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); expect(response.headers['content-type']).toContain('application/pdf'); expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe( '%PDF', ); } }); it('rejects the record card without a colonyId and for an unknown colony', async () => { await request(app.getHttpServer()) .get('/api/v1/export/colony-record-card.pdf') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(400); await request(app.getHttpServer()) .get('/api/v1/export/colony-record-card.pdf?colonyId=does-not-exist') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(404); }); it('renders the Bestandsbuch for a year, also when nothing was treated', async () => { for (const query of ['year=2026', 'year=1999']) { const response = await request(app.getHttpServer()) .get(`/api/v1/export/treatment-log.pdf?${query}`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); expect(response.headers['content-type']).toContain('application/pdf'); expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe( '%PDF', ); } }); it('rejects the apiary annual report without an apiaryId', () => { return request(app.getHttpServer()) .get('/api/v1/export/apiary-annual-report.pdf') .set('Authorization', `Bearer ${beekeeperToken}`) .expect(400); }); it('renders an apiary annual report PDF', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/export/apiary-annual-report.pdf?apiaryId=${apiaryId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); expect(response.headers['content-type']).toContain('application/pdf'); expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe( '%PDF', ); }); describe('Bestandsbuch from the catalogue and a lot', () => { const suffix = Date.now(); const year = 2031; let medicineId: string; let lotId: string; let treatmentColonyId: string; let treatmentApiaryId: string; let treatmentId: string; beforeAll(async () => { treatmentApiaryId = ( await prisma.apiary.create({ data: { name: `Bestandsbuch ${suffix}` } }) ).id; treatmentColonyId = ( await prisma.colony.create({ data: { code: `BB-${suffix}`, apiaryId: treatmentApiaryId, colonyType: 'PRODUCTION_COLONY', }, }) ).id; medicineId = ( await prisma.veterinaryMedicine.create({ data: { name: `Bestand-Mittel ${suffix}`, unit: 'ML', prescriptionOnly: true, }, }) ).id; lotId = ( await prisma.veterinaryMedicineLot.create({ data: { medicineId, purchasedOn: new Date(`${year}-01-10`), quantity: 100, batchNumber: 'CH-4711', supplier: 'Apotheke Bestand, Hauptstr. 9', purchaseProof: 'Rechnung B-77', prescribingVet: 'Dr. Vet Bestand', }, }) ).id; }); afterAll(async () => { await prisma.veterinaryMedicineMovement.deleteMany({ where: { lotId }, }); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'Treatment', aggregateId: treatmentId }, }); await prisma.treatment.deleteMany({ where: { colonyId: treatmentColonyId }, }); await prisma.veterinaryMedicineLot.deleteMany({ where: { id: lotId } }); await prisma.veterinaryMedicine.deleteMany({ where: { id: medicineId }, }); await prisma.colony.deleteMany({ where: { id: treatmentColonyId } }); await prisma.apiary.deleteMany({ where: { id: treatmentApiaryId } }); }); it('a complete catalogue entry and lot give a Bestandsbuch PDF without FEHLT and treatmentLogGaps empty for every row', async () => { const created = await request(app.getHttpServer()) .post('/api/v1/treatments') .set('Authorization', `Bearer ${beekeeperToken}`) .send({ colonyId: treatmentColonyId, date: `${year}-08-01`, medicineLotId: lotId, doseQuantity: 30, withdrawalPeriodDays: 0, }) .expect(201); treatmentId = (created.body as { id: string }).id; const log = await buildTreatmentLog(prisma, year, treatmentApiaryId); const rows = log.sheets.flatMap((sheet) => sheet.rows); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ supplier: 'Apotheke Bestand, Hauptstr. 9', purchaseProof: 'Rechnung B-77', batchNumber: 'CH-4711', prescribingVet: 'Dr. Vet Bestand', amount: '30 ml', prescriptionOnly: true, lotId, }); for (const row of rows) { expect(treatmentLogGaps(row)).toEqual([]); } const response = await request(app.getHttpServer()) .get( `/api/v1/export/treatment-log.pdf?year=${year}&apiaryId=${treatmentApiaryId}`, ) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); // Wrapped cells come out as several lines; a soft hyphen marks the // wrap point. const lines = pdfText(response.body as Buffer) .replaceAll('­', '') .split('\n'); const text = lines.join(' ').replace(/ {2,}/g, ' '); expect(text).toContain('Rechnung B-77'); expect(text).toContain('Apotheke Bestand'); expect(text).toContain('Tierarzt: Dr. Vet Bestand'); expect(text).toContain('30 ml'); // Only the footnote and the holder line (settings of the test DB) may // name FEHLT, no table cell does. const cells = lines .filter( (line) => !line.startsWith('"FEHLT" = ') && !line.startsWith('Name und Anschrift des Bienenhalters'), ) .join('\n'); expect(cells).not.toContain('FEHLT'); }); it('prints FEHLT for a lot without supplier, proof and vet, matching treatmentLogGaps', async () => { const bareYear = year + 1; const bareLot = await prisma.veterinaryMedicineLot.create({ data: { medicineId, purchasedOn: new Date(`${bareYear}-01-10`), quantity: 50, }, }); const created = await request(app.getHttpServer()) .post('/api/v1/treatments') .set('Authorization', `Bearer ${beekeeperToken}`) .send({ colonyId: treatmentColonyId, date: `${bareYear}-08-01`, medicineLotId: bareLot.id, doseQuantity: 5, withdrawalPeriodDays: 0, }) .expect(201); const bareTreatmentId = (created.body as { id: string }).id; try { const log = await buildTreatmentLog( prisma, bareYear, treatmentApiaryId, ); const rows = log.sheets.flatMap((sheet) => sheet.rows); expect(rows).toHaveLength(1); expect(treatmentLogGaps(rows[0])).toEqual(['SUPPLIER', 'PROOF', 'VET']); const response = await request(app.getHttpServer()) .get( `/api/v1/export/treatment-log.pdf?year=${bareYear}&apiaryId=${treatmentApiaryId}`, ) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); const lines = pdfText(response.body as Buffer) .replaceAll('­', '') .split('\n') .filter( (line) => !line.startsWith('"FEHLT" = ') && !line.startsWith('Name und Anschrift des Bienenhalters'), ); const text = lines.join(' ').replace(/ {2,}/g, ' '); // Supplier, vet and proof: exactly three gaps, no more. expect(text.split('FEHLT').length - 1).toBe(3); expect(text).toContain('Tierarzt: FEHLT'); expect(text).toContain('5 ml'); } finally { await prisma.veterinaryMedicineMovement.deleteMany({ where: { lotId: bareLot.id }, }); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'Treatment', aggregateId: bareTreatmentId }, }); await prisma.treatment.deleteMany({ where: { id: bareTreatmentId } }); await prisma.veterinaryMedicineLot.deleteMany({ where: { id: bareLot.id }, }); } }); it('shows the Bestandsbuch only with veterinary-medicines:read in addition to export:read', async () => { const role = await prisma.role.create({ data: { key: `EXPORT_ONLY_${suffix}`, name: 'Export only', permissions: { create: [ { permission: { connect: { key: 'export:read' } } }, { permission: { connect: { key: 'colonies:read' } } }, ], }, }, }); const email = `export-e2e-exportonly-${suffix}@example.com`; const user = await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: 'Export only', role: { connect: { id: role.id } }, }, }); try { const token = (await login(app, email, password)).accessToken; await request(app.getHttpServer()) .get(`/api/v1/export/treatment-log.pdf?year=${year}`) .set('Authorization', `Bearer ${token}`) .expect(403); // Other exports are unchanged. await request(app.getHttpServer()) .get( `/api/v1/export/colony-record-card.pdf?colonyId=${treatmentColonyId}`, ) .set('Authorization', `Bearer ${token}`) .expect(200); } finally { await prisma.refreshToken.deleteMany({ where: { userId: user.id } }); await prisma.user.deleteMany({ where: { id: user.id } }); await prisma.role.deleteMany({ where: { id: role.id } }); } }); }); });