Files
ApiCore/ApiCore-Backend/test/export.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 edc193f128
Backend Image / build-and-push (push) Successful in 5m13s
Frontend Image / build-and-push (push) Successful in 8m54s
feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- Treatments book their dose from a medicine lot (delta booking on edit,
  bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot,
  FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export
  needs veterinary-medicines:read.
- Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with
  watermark, PRINT audit before the PDF, check endpoints; digit rules only
  from the seeded ComplianceSettings row (fail closed).
- Restricted zone (Berlin day): create, update, move, merge and CSV import
  blocked inside an active restriction; override with reason and
  compliance:manage, audited, never stored on the colony; split, harvest
  and dissolve stay free; restriction details only with compliance:read.
- Authority notices: settings, herd snapshot, registration/herd change/TSK
  notices with one open draft per kind under the settings lock.
- Label check refinements (geometric reading order, number|unit pairs).
- ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:18:41 +02:00

453 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<App>;
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 } });
}
});
});
});