- 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]>
453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
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 } });
|
||
}
|
||
});
|
||
});
|
||
});
|