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]>
This commit is contained in:
1 parent
c18860c75c
commit
edc193f128
73 files changed
+9470
-171
No files matched your search
@@ -0,0 +1,376 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { loadHerdApiaryRows } from '../src/compliance/herd-snapshot';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { login } from './utils/api';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
|
||||
interface NoticeBody {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
totalColonies: number;
|
||||
sentOn: string | null;
|
||||
openKey: string | null;
|
||||
existingId?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
describe('Authority notices (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = Date.now();
|
||||
const password = 'SuperSecret123!';
|
||||
const beekeeperEmail = `notices-e2e-beekeeper-${suffix}@example.com`;
|
||||
const staffEmail = `notices-e2e-staff-${suffix}@example.com`;
|
||||
let beekeeperToken: string;
|
||||
let staffToken: string;
|
||||
let noRightsToken: string;
|
||||
let noRightsRoleId: string;
|
||||
const noRightsEmail = `notices-e2e-norights-${suffix}@example.com`;
|
||||
let apiaryId: string;
|
||||
let keyCounter = 0;
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
const as = (token: string) => ({ Authorization: `Bearer ${token}` });
|
||||
const nextKey = () =>
|
||||
`notices-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
for (const [email, roleKey] of [
|
||||
[beekeeperEmail, 'BEEKEEPER'],
|
||||
[staffEmail, 'STAFF'],
|
||||
]) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
name: roleKey,
|
||||
role: { connect: { key: roleKey } },
|
||||
},
|
||||
});
|
||||
}
|
||||
const role = await prisma.role.create({
|
||||
data: { key: `NOTICES_E2E_${suffix}`, name: 'Ohne Rechte' },
|
||||
});
|
||||
noRightsRoleId = role.id;
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: noRightsEmail,
|
||||
passwordHash,
|
||||
name: 'NoRights',
|
||||
roleId: role.id,
|
||||
},
|
||||
});
|
||||
noRightsToken = (await login(app, noRightsEmail, password)).accessToken;
|
||||
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
|
||||
staffToken = (await login(app, staffEmail, password)).accessToken;
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Notices Stand ${suffix}` },
|
||||
});
|
||||
apiaryId = apiary.id;
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `N-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// The test database is shared and not empty: the notices are this file's
|
||||
// own table to clear.
|
||||
await prisma.authorityNotice.deleteMany();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.authorityNotice.deleteMany();
|
||||
await prisma.colony.deleteMany({ where: { apiaryId } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { user: { roleId: noRightsRoleId } },
|
||||
});
|
||||
await prisma.user.deleteMany({ where: { roleId: noRightsRoleId } });
|
||||
await prisma.role.deleteMany({ where: { id: noRightsRoleId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function livingCount(): Promise<number> {
|
||||
const rows = await loadHerdApiaryRows(prisma);
|
||||
return rows.reduce((sum, row) => sum + row.colonyTypes.length, 0);
|
||||
}
|
||||
|
||||
it('two parallel creates of the same draft: one 201, one 409 with existingId', async () => {
|
||||
const send = () =>
|
||||
request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send({ kind: 'REGISTRATION' });
|
||||
|
||||
const responses = await Promise.all([send(), send()]);
|
||||
|
||||
const statuses = responses.map((response) => response.status).sort();
|
||||
expect(statuses).toEqual([201, 409]);
|
||||
const created = responses.find((response) => response.status === 201)!
|
||||
.body as NoticeBody;
|
||||
const conflict = responses.find((response) => response.status === 409)!
|
||||
.body as NoticeBody;
|
||||
expect(conflict.code).toBe('DRAFT_EXISTS');
|
||||
expect(conflict.existingId).toBe(created.id);
|
||||
expect(created).toMatchObject({
|
||||
status: 'DRAFT',
|
||||
openKey: 'REGISTRATION',
|
||||
});
|
||||
expect(created.totalColonies).toBeGreaterThan(0);
|
||||
expect(await prisma.authorityNotice.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('a repeated POST with the same Idempotency-Key returns the stored answer', async () => {
|
||||
const key = nextKey();
|
||||
const send = () =>
|
||||
request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.set('Idempotency-Key', key)
|
||||
.send({ kind: 'REGISTRATION' });
|
||||
|
||||
const first = await send();
|
||||
const second = await send();
|
||||
|
||||
expect(first.status).toBe(201);
|
||||
expect(second.status).toBe(201);
|
||||
expect((second.body as NoticeBody).id).toBe((first.body as NoticeBody).id);
|
||||
expect(await prisma.authorityNotice.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('BEEKEEPER manages notices, STAFF may read but gets 403 on POST', async () => {
|
||||
const created = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'REGISTRATION' })
|
||||
.expect(201);
|
||||
const id = (created.body as NoticeBody).id;
|
||||
|
||||
await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(staffToken))
|
||||
.send({ kind: 'TSK_REPORT', year: 2027 })
|
||||
.expect(403);
|
||||
await request(server())
|
||||
.post(`/api/v1/authority-notices/${id}/cancel`)
|
||||
.set(as(staffToken))
|
||||
.expect(403);
|
||||
await request(server())
|
||||
.patch('/api/v1/compliance/settings')
|
||||
.set(as(staffToken))
|
||||
.send({ noticeOnApiaryChange: false })
|
||||
.expect(403);
|
||||
|
||||
const list = await request(server())
|
||||
.get('/api/v1/authority-notices?kind=REGISTRATION')
|
||||
.set(as(staffToken))
|
||||
.expect(200);
|
||||
expect((list.body as NoticeBody[]).map((notice) => notice.id)).toContain(
|
||||
id,
|
||||
);
|
||||
await request(server())
|
||||
.get(`/api/v1/authority-notices/${id}`)
|
||||
.set(as(staffToken))
|
||||
.expect(200);
|
||||
await request(server())
|
||||
.get('/api/v1/compliance/settings')
|
||||
.set(as(staffToken))
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('a SENT registration with sentOn in the past is the baseline of the next herd change', async () => {
|
||||
await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'HERD_CHANGE' })
|
||||
.expect(409);
|
||||
|
||||
const registration = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'REGISTRATION', sentOn: '2026-01-10' })
|
||||
.expect(201);
|
||||
expect(registration.body).toMatchObject({
|
||||
status: 'SENT',
|
||||
openKey: null,
|
||||
sentOn: '2026-01-10T00:00:00.000Z',
|
||||
});
|
||||
await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'REGISTRATION' })
|
||||
.expect(409);
|
||||
|
||||
// The shared database may change meanwhile: the count lies between the
|
||||
// counts taken before and after the request.
|
||||
const before = await livingCount();
|
||||
const change = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'HERD_CHANGE' })
|
||||
.expect(201);
|
||||
const after = await livingCount();
|
||||
const body = change.body as NoticeBody;
|
||||
expect(body).toMatchObject({ kind: 'HERD_CHANGE', status: 'DRAFT' });
|
||||
expect(body.totalColonies).toBeGreaterThanOrEqual(Math.min(before, after));
|
||||
expect(body.totalColonies).toBeLessThanOrEqual(Math.max(before, after));
|
||||
});
|
||||
|
||||
it('walks a draft through mark-sent, reopen and cancel', async () => {
|
||||
const created = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'TSK_REPORT', year: 2027 })
|
||||
.expect(201);
|
||||
const id = (created.body as NoticeBody).id;
|
||||
const url = `/api/v1/authority-notices/${id}`;
|
||||
|
||||
await request(server())
|
||||
.post(`${url}/mark-sent`)
|
||||
.set(as(beekeeperToken))
|
||||
.send({ sentOn: '2027-02-01', sentVia: 'E-Mail' })
|
||||
.expect(201);
|
||||
await request(server())
|
||||
.post(`${url}/recount`)
|
||||
.set(as(beekeeperToken))
|
||||
.expect(409);
|
||||
await request(server())
|
||||
.patch(url)
|
||||
.set(as(beekeeperToken))
|
||||
.send({ totalColonies: 42 })
|
||||
.expect(200);
|
||||
const reopened = await request(server())
|
||||
.post(`${url}/reopen`)
|
||||
.set(as(beekeeperToken))
|
||||
.expect(201);
|
||||
expect(reopened.body).toMatchObject({
|
||||
status: 'DRAFT',
|
||||
totalColonies: 42,
|
||||
openKey: 'TSK_REPORT:2027',
|
||||
});
|
||||
await request(server())
|
||||
.post(`${url}/cancel`)
|
||||
.set(as(beekeeperToken))
|
||||
.expect(201);
|
||||
await request(server())
|
||||
.post(`${url}/mark-sent`)
|
||||
.set(as(beekeeperToken))
|
||||
.send({})
|
||||
.expect(409);
|
||||
await request(server())
|
||||
.patch(url)
|
||||
.set(as(beekeeperToken))
|
||||
.send({ notes: 'x' })
|
||||
.expect(409);
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { entityType: 'AuthorityNotice', entityId: id },
|
||||
});
|
||||
expect(audit.map((entry) => entry.action).sort()).toEqual([
|
||||
'CREATE',
|
||||
'UPDATE',
|
||||
'UPDATE',
|
||||
'UPDATE',
|
||||
'UPDATE',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reopen answers 409 while another draft of the kind exists', async () => {
|
||||
const sent = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2027-02-01' })
|
||||
.expect(201);
|
||||
const draft = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'TSK_REPORT', year: 2027 })
|
||||
.expect(201);
|
||||
|
||||
const reopen = await request(server())
|
||||
.post(`/api/v1/authority-notices/${(sent.body as NoticeBody).id}/reopen`)
|
||||
.set(as(beekeeperToken))
|
||||
.expect(409);
|
||||
|
||||
expect((reopen.body as NoticeBody).existingId).toBe(
|
||||
(draft.body as NoticeBody).id,
|
||||
);
|
||||
});
|
||||
|
||||
it('a notice recorded as sent is created although a draft is open', async () => {
|
||||
await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'TSK_REPORT', year: 2027 })
|
||||
.expect(201);
|
||||
|
||||
await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2027-02-01' })
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it('a user without compliance rights sees neither notices nor addresses', async () => {
|
||||
const created = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ kind: 'REGISTRATION' })
|
||||
.expect(201);
|
||||
const id = (created.body as NoticeBody).id;
|
||||
|
||||
await request(server())
|
||||
.get('/api/v1/authority-notices')
|
||||
.set(as(noRightsToken))
|
||||
.expect(403);
|
||||
await request(server())
|
||||
.get(`/api/v1/authority-notices/${id}`)
|
||||
.set(as(noRightsToken))
|
||||
.expect(403);
|
||||
await request(server())
|
||||
.get('/api/v1/compliance/settings')
|
||||
.set(as(noRightsToken))
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('validates the compliance settings', async () => {
|
||||
await request(server())
|
||||
.patch('/api/v1/compliance/settings')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ tskReferenceDay: '02-29' })
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.patch('/api/v1/compliance/settings')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ noticeOnApiaryChange: null })
|
||||
.expect(400);
|
||||
|
||||
const settings = await request(server())
|
||||
.patch('/api/v1/compliance/settings')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ tskReferenceDay: '04-15', tskReportDueDay: null })
|
||||
.expect(200);
|
||||
|
||||
expect(settings.body).toMatchObject({
|
||||
tskReferenceDay: '04-15',
|
||||
tskReportDueDay: null,
|
||||
});
|
||||
await request(server())
|
||||
.patch('/api/v1/compliance/settings')
|
||||
.set(as(beekeeperToken))
|
||||
.send({ tskReferenceDay: '01-01' })
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,39 @@ describe('Colonies (e2e)', () => {
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('accepts restrictionOverrideReason without a restriction, ignores it and never stores it', async () => {
|
||||
const reason = 'Ohne Sperrbezirk wird die Begründung nur ignoriert';
|
||||
const code = `V-${Date.now()}-R`;
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/colonies')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({
|
||||
code,
|
||||
apiaryId: apiaryAId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
restrictionOverrideReason: reason,
|
||||
})
|
||||
.expect(201);
|
||||
const created = response.body as ColonyBody;
|
||||
splitColonyIds.push(created.id);
|
||||
expect(JSON.stringify(created)).not.toContain(reason);
|
||||
const stored = await prisma.colony.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(JSON.stringify(stored)).not.toContain(reason);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/colonies')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({
|
||||
code: `${code}2`,
|
||||
apiaryId: apiaryAId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
restrictionOverrideReason: 'zu kurz',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('lets a BEEKEEPER create a colony', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/colonies')
|
||||
|
||||
@@ -2,8 +2,13 @@ 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)', () => {
|
||||
@@ -214,4 +219,234 @@ describe('Export (e2e)', () => {
|
||||
'%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 } });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -193,6 +193,36 @@ describe('Import (e2e)', () => {
|
||||
expect(report.rows[0].message).toContain('nicht gefunden');
|
||||
});
|
||||
|
||||
it('refuses to import a colony into an apiary in a restricted zone', async () => {
|
||||
const code = `CSV-V-Zone-${Date.now()}`;
|
||||
const csv = `code,apiaryName,colonyType
|
||||
${code},${apiaryName},PRODUCTION_COLONY`;
|
||||
const restriction = await prisma.apiaryRestriction.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
disease: 'Faulbrut',
|
||||
startsOn: new Date('2000-01-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/import/colonies')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.attach('file', csvFile(csv), {
|
||||
filename: 'colonies.csv',
|
||||
contentType: 'text/csv',
|
||||
})
|
||||
.expect(201);
|
||||
const report = response.body as ImportReportBody;
|
||||
expect(report.created).toBe(0);
|
||||
expect(report.errors).toBe(1);
|
||||
expect(report.rows[0].message).toContain('Sperrbezirk');
|
||||
expect(await prisma.colony.count({ where: { code } })).toBe(0);
|
||||
} finally {
|
||||
await prisma.apiaryRestriction.delete({ where: { id: restriction.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('imports queens', async () => {
|
||||
const code = `CSV-Q-${Date.now()}`;
|
||||
const csv = `code,breed\n${code},Carnica`;
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
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';
|
||||
|
||||
interface GapBody {
|
||||
code: string;
|
||||
}
|
||||
interface ConflictBody {
|
||||
code: string;
|
||||
message: string;
|
||||
gaps: GapBody[];
|
||||
}
|
||||
interface CheckBody {
|
||||
printable: boolean;
|
||||
sample: boolean;
|
||||
gaps: GapBody[];
|
||||
warnings: GapBody[];
|
||||
}
|
||||
|
||||
const SIZE = 80;
|
||||
|
||||
function textElement(
|
||||
id: string,
|
||||
y: number,
|
||||
placeholderKey: string,
|
||||
fontSize = 9,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
type: 'text',
|
||||
x: 2,
|
||||
y,
|
||||
width: SIZE,
|
||||
height: 8,
|
||||
text: '',
|
||||
placeholderKey,
|
||||
fontSize,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
};
|
||||
}
|
||||
|
||||
// Every mandatory particular, the net quantity big enough for 500 g.
|
||||
const COMPLETE_ELEMENTS = [
|
||||
textElement('legal', 1, 'legalName'),
|
||||
textElement('net', 10, 'jarSize', 16),
|
||||
textElement('mhd', 20, 'bestBeforeLine'),
|
||||
textElement('lot', 29, 'lotLine'),
|
||||
textElement('origin', 38, 'originCountry'),
|
||||
textElement('firm', 47, 'foodBusinessName'),
|
||||
textElement('address', 56, 'foodBusinessAddress'),
|
||||
];
|
||||
|
||||
describe('Label compliance (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = Date.now();
|
||||
const password = 'SuperSecret123!';
|
||||
const printerEmail = `label-compliance-printer-${suffix}@example.com`;
|
||||
const readerEmail = `label-compliance-reader-${suffix}@example.com`;
|
||||
let printerId: string;
|
||||
let readerId: string;
|
||||
let readerRoleId: string;
|
||||
let labelOnlyId: string;
|
||||
let labelOnlyRoleId: string;
|
||||
let labelOnlyToken: string;
|
||||
const labelOnlyEmail = `label-compliance-labelonly-${suffix}@example.com`;
|
||||
let printerToken: string;
|
||||
let readerToken: string;
|
||||
let productId: string;
|
||||
let templateId: string;
|
||||
let honeyBatchId: string;
|
||||
let extractionId: string;
|
||||
let bottlingId: string;
|
||||
let settingsBefore: {
|
||||
foodBusinessName: string | null;
|
||||
foodBusinessAddress: string | null;
|
||||
originCountry: string;
|
||||
} | null = null;
|
||||
|
||||
async function setMasterData(complete: boolean): Promise<void> {
|
||||
// Other suites clear this table; the check refuses without the row.
|
||||
await prisma.complianceSettings.upsert({
|
||||
where: { id: 'singleton' },
|
||||
update: {},
|
||||
create: { id: 'singleton' },
|
||||
});
|
||||
const data = {
|
||||
foodBusinessName: complete ? 'Imkerei Muster' : null,
|
||||
foodBusinessAddress: complete ? 'Bühlweg 10, 86450 Altenmünster' : null,
|
||||
originCountry: 'Deutschland',
|
||||
};
|
||||
await prisma.siteSettings.upsert({
|
||||
where: { id: 'singleton' },
|
||||
update: data,
|
||||
create: { id: 'singleton', ...data },
|
||||
});
|
||||
await prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: { legalName: complete ? 'Blütenhonig' : null },
|
||||
});
|
||||
}
|
||||
|
||||
const printUrl = (query = '') =>
|
||||
`/api/v1/label-templates/${templateId}/print/${bottlingId}${query}`;
|
||||
|
||||
const printAuditCount = () =>
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: bottlingId,
|
||||
action: 'PRINT',
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const beekeeper = await prisma.user.create({
|
||||
data: {
|
||||
email: printerEmail,
|
||||
passwordHash,
|
||||
name: 'Printer',
|
||||
role: { connect: { key: 'ADMIN' } },
|
||||
},
|
||||
});
|
||||
printerId = beekeeper.id;
|
||||
// The test database grants the label permissions to ADMIN only, so the
|
||||
// "can look but not print" user gets a role of its own.
|
||||
const [readPermission, bottlingRead] = await Promise.all([
|
||||
prisma.permission.findUniqueOrThrow({
|
||||
where: { key: 'label-templates:read' },
|
||||
}),
|
||||
prisma.permission.findUniqueOrThrow({ where: { key: 'bottling:read' } }),
|
||||
]);
|
||||
const readerRole = await prisma.role.create({
|
||||
data: {
|
||||
key: `label-compliance-reader-${suffix}`,
|
||||
name: 'Label and bottling reader',
|
||||
permissions: {
|
||||
create: [
|
||||
{ permissionId: readPermission.id },
|
||||
{ permissionId: bottlingRead.id },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
readerRoleId = readerRole.id;
|
||||
const reader = await prisma.user.create({
|
||||
data: {
|
||||
email: readerEmail,
|
||||
passwordHash,
|
||||
name: 'Reader',
|
||||
roleId: readerRole.id,
|
||||
},
|
||||
});
|
||||
readerId = reader.id;
|
||||
// Sees label templates, but not bottlings.
|
||||
const labelOnlyRole = await prisma.role.create({
|
||||
data: {
|
||||
key: `label-compliance-labelonly-${suffix}`,
|
||||
name: 'Label reader',
|
||||
permissions: { create: { permissionId: readPermission.id } },
|
||||
},
|
||||
});
|
||||
labelOnlyRoleId = labelOnlyRole.id;
|
||||
const labelOnly = await prisma.user.create({
|
||||
data: {
|
||||
email: labelOnlyEmail,
|
||||
passwordHash,
|
||||
name: 'Label only',
|
||||
roleId: labelOnlyRole.id,
|
||||
},
|
||||
});
|
||||
labelOnlyId = labelOnly.id;
|
||||
labelOnlyToken = (await login(app, labelOnlyEmail, password)).accessToken;
|
||||
printerToken = (await login(app, printerEmail, password)).accessToken;
|
||||
readerToken = (await login(app, readerEmail, password)).accessToken;
|
||||
|
||||
settingsBefore = await prisma.siteSettings.findUnique({
|
||||
where: { id: 'singleton' },
|
||||
select: {
|
||||
foodBusinessName: true,
|
||||
foodBusinessAddress: true,
|
||||
originCountry: true,
|
||||
},
|
||||
});
|
||||
|
||||
const product = await prisma.product.create({
|
||||
data: { name: `Label-Compliance-Glas ${suffix}` },
|
||||
});
|
||||
productId = product.id;
|
||||
const extraction = await prisma.extraction.create({
|
||||
data: { date: new Date(), userId: printerId },
|
||||
});
|
||||
extractionId = extraction.id;
|
||||
const honeyBatch = await prisma.honeyBatch.create({
|
||||
data: {
|
||||
batchNumber: `LC-${suffix}`,
|
||||
extractionId,
|
||||
amount: 25,
|
||||
initialAmount: 25,
|
||||
},
|
||||
});
|
||||
honeyBatchId = honeyBatch.id;
|
||||
const bottling = await prisma.bottlingBatch.create({
|
||||
data: {
|
||||
honeyBatchId,
|
||||
productId,
|
||||
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||
jarSize: 500,
|
||||
jarCount: 2,
|
||||
totalAmount: 1,
|
||||
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||
lotNumber: `L-${suffix}`,
|
||||
userId: printerId,
|
||||
},
|
||||
});
|
||||
bottlingId = bottling.id;
|
||||
const template = await prisma.labelTemplate.create({
|
||||
data: {
|
||||
name: `Label-Compliance ${suffix}`,
|
||||
elements: COMPLETE_ELEMENTS,
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
},
|
||||
});
|
||||
templateId = template.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: { entityType: 'BottlingBatch', entityId: bottlingId },
|
||||
});
|
||||
await prisma.bottlingBatch.deleteMany({ where: { id: bottlingId } });
|
||||
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
|
||||
await prisma.extraction.deleteMany({ where: { id: extractionId } });
|
||||
await prisma.labelTemplate.deleteMany({ where: { id: templateId } });
|
||||
await prisma.product.deleteMany({ where: { id: productId } });
|
||||
if (settingsBefore) {
|
||||
await prisma.siteSettings.update({
|
||||
where: { id: 'singleton' },
|
||||
data: settingsBefore,
|
||||
});
|
||||
}
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: [printerId, readerId, labelOnlyId] } },
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: [printerId, readerId, labelOnlyId] } },
|
||||
});
|
||||
await prisma.role.deleteMany({
|
||||
where: { id: { in: [readerRoleId, labelOnlyRoleId] } },
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('409 with the gap list, then 200 and an audit PRINT after the master data is complete', async () => {
|
||||
await setMasterData(false);
|
||||
|
||||
const refused = await request(app.getHttpServer())
|
||||
.get(printUrl())
|
||||
.set('Authorization', `Bearer ${printerToken}`)
|
||||
.expect(409);
|
||||
const body = refused.body as ConflictBody;
|
||||
expect(body.code).toBe('LABEL_PARTICULARS_MISSING');
|
||||
expect(body.gaps.map((gap) => gap.code).sort()).toEqual([
|
||||
'FOOD_BUSINESS',
|
||||
'LEGAL_NAME',
|
||||
]);
|
||||
expect(await printAuditCount()).toBe(0);
|
||||
|
||||
await setMasterData(true);
|
||||
const printed = await request(app.getHttpServer())
|
||||
.get(printUrl())
|
||||
.set('Authorization', `Bearer ${printerToken}`)
|
||||
.expect(200);
|
||||
expect(printed.headers['content-type']).toContain('application/pdf');
|
||||
expect((printed.body as Buffer).subarray(0, 4).toString('latin1')).toBe(
|
||||
'%PDF',
|
||||
);
|
||||
|
||||
const entries = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: bottlingId,
|
||||
action: 'PRINT',
|
||||
},
|
||||
});
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].userId).toBe(printerId);
|
||||
expect(entries[0].afterData).toEqual({
|
||||
templateId,
|
||||
copies: 2,
|
||||
proof: false,
|
||||
gapCodes: [],
|
||||
warningCodes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('GET print with proof=true answers 200 although a particular is missing', async () => {
|
||||
await setMasterData(false);
|
||||
const before = await printAuditCount();
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(printUrl('?proof=true'))
|
||||
.set('Authorization', `Bearer ${printerToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe(
|
||||
'%PDF',
|
||||
);
|
||||
expect(await printAuditCount()).toBe(before + 1);
|
||||
const latest = await prisma.auditLog.findFirst({
|
||||
where: { entityType: 'BottlingBatch', entityId: bottlingId },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
});
|
||||
expect(latest?.afterData).toMatchObject({
|
||||
proof: true,
|
||||
gapCodes: expect.arrayContaining(['LEGAL_NAME']) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a malformed proof flag with 400', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(printUrl('?proof=maybe'))
|
||||
.set('Authorization', `Bearer ${printerToken}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('GET check answers the same gaps the print refuses with', async () => {
|
||||
await setMasterData(false);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/label-templates/${templateId}/check/${bottlingId}`)
|
||||
.set('Authorization', `Bearer ${readerToken}`)
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as CheckBody;
|
||||
expect(body.printable).toBe(false);
|
||||
expect(body.sample).toBe(false);
|
||||
expect(body.gaps.map((gap) => gap.code).sort()).toEqual([
|
||||
'FOOD_BUSINESS',
|
||||
'LEGAL_NAME',
|
||||
]);
|
||||
expect(response.headers['cache-control']).toContain('no-store');
|
||||
});
|
||||
|
||||
it('POST check without a bottling uses the sample values', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/label-templates/check')
|
||||
.set('Authorization', `Bearer ${readerToken}`)
|
||||
.send({
|
||||
elements: [textElement('legal', 1, 'legalName')],
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as CheckBody;
|
||||
expect(body.sample).toBe(true);
|
||||
expect(body.gaps.map((gap) => gap.code)).not.toContain('LEGAL_NAME');
|
||||
});
|
||||
|
||||
it('POST check rejects an image element with 400', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/label-templates/check')
|
||||
.set('Authorization', `Bearer ${readerToken}`)
|
||||
.send({
|
||||
elements: [
|
||||
{
|
||||
id: 'img',
|
||||
type: 'image',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
fontSize: 9,
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
},
|
||||
],
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('POST check with 101 elements answers 400', async () => {
|
||||
const elements = Array.from({ length: 101 }, (_, index) =>
|
||||
textElement(`el-${index}`, 1, 'legalName'),
|
||||
);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/label-templates/check')
|
||||
.set('Authorization', `Bearer ${readerToken}`)
|
||||
.send({ elements, widthMm: 90, heightMm: 70, minXHeightMm: 1.2 })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('print needs label-templates:print, check needs label-templates:read', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(printUrl('?proof=true'))
|
||||
.set('Authorization', `Bearer ${readerToken}`)
|
||||
.expect(403);
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v1/label-templates/${templateId}/check/${bottlingId}`)
|
||||
.expect(401);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/label-templates/check')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('?proof=false with a gap answers 409, ?proof=0 answers 400', async () => {
|
||||
await setMasterData(false);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(printUrl('?proof=false'))
|
||||
.set('Authorization', `Bearer ${printerToken}`)
|
||||
.expect(409);
|
||||
await request(app.getHttpServer())
|
||||
.get(printUrl('?proof=0'))
|
||||
.set('Authorization', `Bearer ${printerToken}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it.each(['0', '-1', '1001', 'x'])(
|
||||
'startPosition=%s answers 400',
|
||||
async (value) => {
|
||||
await setMasterData(true);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(printUrl(`?startPosition=${value}`))
|
||||
.set('Authorization', `Bearer ${printerToken}`)
|
||||
.expect(400);
|
||||
},
|
||||
);
|
||||
|
||||
it('a label-only reader gets 403 on check and print, and on a draft check with a bottling', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v1/label-templates/${templateId}/check/${bottlingId}`)
|
||||
.set('Authorization', `Bearer ${labelOnlyToken}`)
|
||||
.expect(403);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/label-templates/check')
|
||||
.set('Authorization', `Bearer ${labelOnlyToken}`)
|
||||
.send({
|
||||
elements: [textElement('legal', 1, 'legalName')],
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
bottlingBatchId: bottlingId,
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('a label-only reader may check a draft with the sample values', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/label-templates/check')
|
||||
.set('Authorization', `Bearer ${labelOnlyToken}`)
|
||||
.send({
|
||||
elements: [textElement('legal', 1, 'legalName')],
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('POST check rejects duplicate element ids and out-of-range sizes with 400', async () => {
|
||||
const draft = (elements: unknown[]) => ({
|
||||
elements,
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
});
|
||||
const element = textElement('same', 1, 'legalName');
|
||||
|
||||
for (const body of [
|
||||
draft([element, element]),
|
||||
draft([{ ...element, width: 5000 }]),
|
||||
draft([{ ...element, x: -5000 }]),
|
||||
{ ...draft([element]), widthMm: 5000 },
|
||||
]) {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/label-templates/check')
|
||||
.set('Authorization', `Bearer ${readerToken}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('print and check answer 409 when the compliance settings row is missing', async () => {
|
||||
await setMasterData(true);
|
||||
const saved = await prisma.complianceSettings.findUniqueOrThrow({
|
||||
where: { id: 'singleton' },
|
||||
});
|
||||
const restore = {
|
||||
...saved,
|
||||
netQuantityDigitRules: saved.netQuantityDigitRules as object,
|
||||
};
|
||||
await prisma.complianceSettings.delete({ where: { id: 'singleton' } });
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(printUrl())
|
||||
.set('Authorization', `Bearer ${printerToken}`)
|
||||
.expect(409);
|
||||
expect((response.body as { code: string }).code).toBe(
|
||||
'COMPLIANCE_SETTINGS_INVALID',
|
||||
);
|
||||
} finally {
|
||||
await prisma.complianceSettings.create({ data: restore });
|
||||
}
|
||||
});
|
||||
|
||||
it('the response of a print is no-store', async () => {
|
||||
await setMasterData(true);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(printUrl())
|
||||
.set('Authorization', `Bearer ${printerToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toContain('no-store');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,695 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { addDays, berlinToday, dayToDate } from '../src/common/dates';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { lockRowsForShare } from '../src/prisma/row-lock';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
|
||||
interface ColonyBody {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface RestrictedBody {
|
||||
code: string;
|
||||
message: string;
|
||||
restrictionIds: string[];
|
||||
restrictions: {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
disease: string;
|
||||
startsOn: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface RestrictionBody {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
endsOn: string | null;
|
||||
}
|
||||
|
||||
const LIFECYCLE_PERMISSIONS = [
|
||||
'colonies:read',
|
||||
'colonies:create',
|
||||
'colonies:update',
|
||||
'colonies:manage-lifecycle',
|
||||
'apiaries:read',
|
||||
];
|
||||
const REASON = 'Amtstierarzt hat die Verbringung am Telefon genehmigt';
|
||||
const DISEASE = 'Amerikanische Faulbrut';
|
||||
|
||||
describe('Restricted zone (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = Date.now();
|
||||
const password = 'SuperSecret123!';
|
||||
const userIds: string[] = [];
|
||||
const roleIds: string[] = [];
|
||||
let beekeeperToken: string;
|
||||
let lifecycleToken: string;
|
||||
let staffToken: string;
|
||||
let readonlyToken: string;
|
||||
let apiariesOnlyToken: string;
|
||||
let apiaryRestrictedId: string;
|
||||
let apiaryFreeId: string;
|
||||
let apiaryOtherId: string;
|
||||
const colonyIds: string[] = [];
|
||||
|
||||
async function user(
|
||||
label: string,
|
||||
role: { connect: { key: string } } | { connect: { id: string } },
|
||||
): Promise<string> {
|
||||
const email = `restricted-zone-${label}-${suffix}@example.com`;
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: label,
|
||||
role,
|
||||
},
|
||||
});
|
||||
userIds.push(created.id);
|
||||
return (await login(app, email, password)).accessToken;
|
||||
}
|
||||
|
||||
async function colonyIn(apiaryId: string): Promise<string> {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `RZ-${suffix}-${colonyIds.length}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyIds.push(colony.id);
|
||||
return colony.id;
|
||||
}
|
||||
|
||||
function restrict(apiaryId: string, endsOn: string | null = null) {
|
||||
return prisma.apiaryRestriction.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
disease: DISEASE,
|
||||
startsOn: dayToDate('2000-01-01'),
|
||||
endsOn: endsOn ? dayToDate(endsOn) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const apiaryIds = (): string[] => [
|
||||
apiaryRestrictedId,
|
||||
apiaryFreeId,
|
||||
apiaryOtherId,
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `RZ_LIFECYCLE_${suffix}`,
|
||||
name: 'Voelker ohne Behoerden',
|
||||
permissions: {
|
||||
create: LIFECYCLE_PERMISSIONS.map((key) => ({
|
||||
permission: { connect: { key } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
roleIds.push(role.id);
|
||||
beekeeperToken = await user('beekeeper', { connect: { key: 'BEEKEEPER' } });
|
||||
lifecycleToken = await user('lifecycle', { connect: { id: role.id } });
|
||||
staffToken = await user('staff', { connect: { key: 'STAFF' } });
|
||||
readonlyToken = await user('readonly', { connect: { key: 'READONLY' } });
|
||||
const apiariesOnly = await prisma.role.create({
|
||||
data: {
|
||||
key: `RZ_APIARIES_ONLY_${suffix}`,
|
||||
name: 'Nur Staende',
|
||||
permissions: {
|
||||
create: [{ permission: { connect: { key: 'apiaries:read' } } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
roleIds.push(apiariesOnly.id);
|
||||
apiariesOnlyToken = await user('apiaries-only', {
|
||||
connect: { id: apiariesOnly.id },
|
||||
});
|
||||
|
||||
apiaryRestrictedId = (
|
||||
await prisma.apiary.create({ data: { name: `RZ Sperre ${suffix}` } })
|
||||
).id;
|
||||
apiaryFreeId = (
|
||||
await prisma.apiary.create({ data: { name: `RZ Frei ${suffix}` } })
|
||||
).id;
|
||||
apiaryOtherId = (
|
||||
await prisma.apiary.create({ data: { name: `RZ Weitere ${suffix}` } })
|
||||
).id;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.apiaryRestriction.deleteMany({
|
||||
where: { apiaryId: { in: apiaryIds() } },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.apiaryRestriction.deleteMany({
|
||||
where: { apiaryId: { in: apiaryIds() } },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
|
||||
});
|
||||
const harvests = await prisma.harvest.findMany({
|
||||
where: { apiaryId: { in: apiaryIds() } },
|
||||
select: { id: true },
|
||||
});
|
||||
const harvestIds = harvests.map((harvest) => harvest.id);
|
||||
await prisma.harvestColony.deleteMany({
|
||||
where: { harvestId: { in: harvestIds } },
|
||||
});
|
||||
await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } });
|
||||
await prisma.colonyLineage.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromColonyId: { in: colonyIds } },
|
||||
{ toColonyId: { in: colonyIds } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.colonyApiaryHistory.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.colony.deleteMany({
|
||||
where: {
|
||||
OR: [{ id: { in: colonyIds } }, { apiaryId: { in: apiaryIds() } }],
|
||||
},
|
||||
});
|
||||
await prisma.apiary.deleteMany({ where: { id: { in: apiaryIds() } } });
|
||||
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 prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('update with a changed apiary, move, merge across apiaries and create each answer 409 RESTRICTED_ZONE', async () => {
|
||||
await restrict(apiaryRestrictedId);
|
||||
const inFree = await colonyIn(apiaryFreeId);
|
||||
const inRestricted = await colonyIn(apiaryRestrictedId);
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
||||
|
||||
const created = await http()
|
||||
.post('/api/v1/colonies')
|
||||
.set(auth)
|
||||
.send({
|
||||
code: `RZ-new-${suffix}`,
|
||||
apiaryId: apiaryRestrictedId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
})
|
||||
.expect(409);
|
||||
const body = created.body as RestrictedBody;
|
||||
expect(body.code).toBe('RESTRICTED_ZONE');
|
||||
expect(body.restrictionIds).toHaveLength(1);
|
||||
expect(body.restrictions[0]).toMatchObject({
|
||||
apiaryId: apiaryRestrictedId,
|
||||
apiaryName: `RZ Sperre ${suffix}`,
|
||||
disease: DISEASE,
|
||||
startsOn: '2000-01-01',
|
||||
});
|
||||
expect(body.message).toContain(`RZ Sperre ${suffix}`);
|
||||
expect(
|
||||
await prisma.colony.count({ where: { code: `RZ-new-${suffix}` } }),
|
||||
).toBe(0);
|
||||
|
||||
await http()
|
||||
.patch(`/api/v1/colonies/${inFree}`)
|
||||
.set(auth)
|
||||
.send({ apiaryId: apiaryRestrictedId })
|
||||
.expect(409);
|
||||
await http()
|
||||
.post(`/api/v1/colonies/${inFree}/move`)
|
||||
.set(auth)
|
||||
.send({ apiaryId: apiaryRestrictedId })
|
||||
.expect(409);
|
||||
await http()
|
||||
.post(`/api/v1/colonies/${inRestricted}/move`)
|
||||
.set(auth)
|
||||
.send({ apiaryId: apiaryFreeId })
|
||||
.expect(409);
|
||||
await http()
|
||||
.patch(`/api/v1/colonies/${inRestricted}`)
|
||||
.set(auth)
|
||||
.send({ apiaryId: apiaryFreeId })
|
||||
.expect(409);
|
||||
await http()
|
||||
.post(`/api/v1/colonies/${inFree}/merge`)
|
||||
.set(auth)
|
||||
.send({ mergedColonyIds: [inRestricted] })
|
||||
.expect(409);
|
||||
|
||||
const after = await prisma.colony.findMany({
|
||||
where: { id: { in: [inFree, inRestricted] } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
expect(
|
||||
after.map((colony) => [colony.id, colony.apiaryId, colony.status]),
|
||||
).toEqual(
|
||||
[
|
||||
[inFree, apiaryFreeId, 'ACTIVE'],
|
||||
[inRestricted, apiaryRestrictedId, 'ACTIVE'],
|
||||
].sort(([a], [b]) => (a < b ? -1 : 1)),
|
||||
);
|
||||
});
|
||||
|
||||
it('an override with compliance:manage answers 200, writes audit and the history note, and never stores the reason on the colony', async () => {
|
||||
const restriction = await restrict(apiaryRestrictedId);
|
||||
const colonyId = await colonyIn(apiaryFreeId);
|
||||
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
||||
|
||||
const patched = await request(app.getHttpServer())
|
||||
.patch(`/api/v1/colonies/${colonyId}`)
|
||||
.set(auth)
|
||||
.send({ apiaryId: apiaryRestrictedId, restrictionOverrideReason: REASON })
|
||||
.expect(200);
|
||||
expect((patched.body as ColonyBody).apiaryId).toBe(apiaryRestrictedId);
|
||||
const moved = await request(app.getHttpServer())
|
||||
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||
.set(auth)
|
||||
.send({
|
||||
apiaryId: apiaryFreeId,
|
||||
notes: 'Rueckfuehrung',
|
||||
restrictionOverrideReason: REASON,
|
||||
})
|
||||
.expect(201);
|
||||
expect((moved.body as ColonyBody).apiaryId).toBe(apiaryFreeId);
|
||||
|
||||
const colony = await prisma.colony.findUniqueOrThrow({
|
||||
where: { id: colonyId },
|
||||
});
|
||||
expect(JSON.stringify(colony)).not.toContain(REASON);
|
||||
const history = await prisma.colonyApiaryHistory.findMany({
|
||||
where: { colonyId },
|
||||
orderBy: { movedAt: 'asc' },
|
||||
});
|
||||
expect(history.map((entry) => entry.notes)).toEqual([
|
||||
`Ausnahme Sperrbezirk: ${REASON}\nKorrektur über Volk bearbeiten`,
|
||||
`Ausnahme Sperrbezirk: ${REASON}\nRueckfuehrung`,
|
||||
]);
|
||||
const audits = await prisma.auditLog.findMany({
|
||||
where: { entityType: 'Colony', entityId: colonyId, action: 'UPDATE' },
|
||||
});
|
||||
expect(audits).toHaveLength(2);
|
||||
for (const audit of audits) {
|
||||
expect(
|
||||
(audit.afterData as { restrictionOverride: unknown })
|
||||
.restrictionOverride,
|
||||
).toEqual({ reason: REASON, restrictionIds: [restriction.id] });
|
||||
}
|
||||
});
|
||||
|
||||
it('an override without compliance:manage answers 403 and an override reason of 5 characters answers 400', async () => {
|
||||
await restrict(apiaryRestrictedId);
|
||||
const colonyId = await colonyIn(apiaryFreeId);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||
.set('Authorization', `Bearer ${lifecycleToken}`)
|
||||
.send({ apiaryId: apiaryRestrictedId, restrictionOverrideReason: REASON })
|
||||
.expect(403);
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({
|
||||
apiaryId: apiaryRestrictedId,
|
||||
restrictionOverrideReason: 'kurz!',
|
||||
})
|
||||
.expect(400);
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({
|
||||
apiaryId: apiaryRestrictedId,
|
||||
restrictionOverrideReason: ' '.repeat(12),
|
||||
})
|
||||
.expect(400);
|
||||
// A reason without compliance:manage is a 403 even when nothing is restricted.
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||
.set('Authorization', `Bearer ${lifecycleToken}`)
|
||||
.send({ apiaryId: apiaryOtherId, restrictionOverrideReason: REASON })
|
||||
.expect(403);
|
||||
expect(
|
||||
(await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }))
|
||||
.apiaryId,
|
||||
).toBe(apiaryFreeId);
|
||||
});
|
||||
|
||||
it('split inside the apiary and a harvest at a restricted apiary succeed', async () => {
|
||||
await restrict(apiaryRestrictedId);
|
||||
const colonyId = await colonyIn(apiaryRestrictedId);
|
||||
|
||||
const split = await request(app.getHttpServer())
|
||||
.post(`/api/v1/colonies/${colonyId}/split`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ code: `RZ-split-${suffix}`, colonyType: 'NUCLEUS_COLONY' })
|
||||
.expect(201);
|
||||
colonyIds.push((split.body as ColonyBody).id);
|
||||
expect((split.body as ColonyBody).apiaryId).toBe(apiaryRestrictedId);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/harvests')
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({
|
||||
apiaryId: apiaryRestrictedId,
|
||||
date: new Date().toISOString(),
|
||||
colonyIds: [colonyId],
|
||||
netWeight: 12,
|
||||
})
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it('a restriction ending yesterday no longer blocks, one ending today still does', async () => {
|
||||
const today = berlinToday();
|
||||
const colonyId = await colonyIn(apiaryFreeId);
|
||||
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
||||
await restrict(apiaryRestrictedId, today);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||
.set(auth)
|
||||
.send({ apiaryId: apiaryRestrictedId })
|
||||
.expect(409);
|
||||
|
||||
await prisma.apiaryRestriction.updateMany({
|
||||
where: { apiaryId: apiaryRestrictedId },
|
||||
data: { endsOn: dayToDate(addDays(today, -1)) },
|
||||
});
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||
.set(auth)
|
||||
.send({ apiaryId: apiaryRestrictedId })
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it('GET /public routes never contain restriction data', async () => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: { id: { in: userIds } },
|
||||
});
|
||||
const lotNumber = `RZ-LOT-${suffix}`;
|
||||
await restrict(apiaryRestrictedId);
|
||||
const colonyId = await colonyIn(apiaryRestrictedId);
|
||||
const harvest = await prisma.harvest.create({
|
||||
data: {
|
||||
apiaryId: apiaryRestrictedId,
|
||||
date: new Date('2026-08-12'),
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
await prisma.harvestColony.create({
|
||||
data: { harvestId: harvest.id, colonyId },
|
||||
});
|
||||
const extraction = await prisma.extraction.create({
|
||||
data: { date: new Date('2026-08-13'), userId: user.id },
|
||||
});
|
||||
await prisma.extractionHarvest.create({
|
||||
data: { extractionId: extraction.id, harvestId: harvest.id },
|
||||
});
|
||||
const honeyBatch = await prisma.honeyBatch.create({
|
||||
data: {
|
||||
batchNumber: `RZ-HC-${suffix}`,
|
||||
extractionId: extraction.id,
|
||||
amount: 25,
|
||||
honeyType: 'Sommerhonig',
|
||||
},
|
||||
});
|
||||
const product = await prisma.product.create({
|
||||
data: { name: `RZ Honig ${suffix}` },
|
||||
});
|
||||
const bottling = await prisma.bottlingBatch.create({
|
||||
data: {
|
||||
honeyBatchId: honeyBatch.id,
|
||||
productId: product.id,
|
||||
date: new Date('2026-08-20'),
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
bestBeforeDate: new Date('2028-08-20'),
|
||||
lotNumber,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/public/batches/${lotNumber}`)
|
||||
.expect(200);
|
||||
const text = JSON.stringify(response.body).toLowerCase();
|
||||
for (const secret of [DISEASE, 'faulbrut', 'restriction', 'sperr']) {
|
||||
expect(text).not.toContain(secret.toLowerCase());
|
||||
}
|
||||
} finally {
|
||||
await prisma.bottlingBatch.delete({ where: { id: bottling.id } });
|
||||
await prisma.product.delete({ where: { id: product.id } });
|
||||
await prisma.honeyBatch.delete({ where: { id: honeyBatch.id } });
|
||||
await prisma.extractionHarvest.deleteMany({
|
||||
where: { extractionId: extraction.id },
|
||||
});
|
||||
await prisma.extraction.delete({ where: { id: extraction.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('READONLY may read restrictions but not create them (403)', async () => {
|
||||
const auth = { Authorization: `Bearer ${readonlyToken}` };
|
||||
const restriction = await restrict(apiaryRestrictedId);
|
||||
|
||||
const active = await request(app.getHttpServer())
|
||||
.get('/api/v1/apiary-restrictions?active=true')
|
||||
.set(auth)
|
||||
.expect(200);
|
||||
expect(
|
||||
(active.body as RestrictedBody['restrictions']).map((entry) => entry.id),
|
||||
).toContain(restriction.id);
|
||||
const ofApiary = await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${apiaryRestrictedId}/restrictions`)
|
||||
.set(auth)
|
||||
.expect(200);
|
||||
expect(
|
||||
(ofApiary.body as RestrictionBody[]).map((entry) => entry.id),
|
||||
).toEqual([restriction.id]);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/apiary-restrictions')
|
||||
.set(auth)
|
||||
.send({
|
||||
apiaryIds: [apiaryFreeId],
|
||||
disease: DISEASE,
|
||||
startsOn: '2026-10-01',
|
||||
})
|
||||
.expect(403);
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/apiary-restrictions/${restriction.id}`)
|
||||
.set(auth)
|
||||
.send({ endsOn: '2026-12-01' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates one restriction per apiary, ends it by endsOn and keeps the row', async () => {
|
||||
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
||||
|
||||
const created = await request(app.getHttpServer())
|
||||
.post('/api/v1/apiary-restrictions')
|
||||
.set(auth)
|
||||
.send({
|
||||
apiaryIds: [apiaryFreeId, apiaryOtherId],
|
||||
disease: DISEASE,
|
||||
startsOn: '2026-10-01',
|
||||
authority: 'Veterinaeramt',
|
||||
})
|
||||
.expect(201);
|
||||
const rows = created.body as RestrictionBody[];
|
||||
expect(rows.map((row) => row.apiaryId).sort()).toEqual(
|
||||
[apiaryFreeId, apiaryOtherId].sort(),
|
||||
);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/apiary-restrictions')
|
||||
.set(auth)
|
||||
.send({
|
||||
apiaryIds: [apiaryFreeId, 'does-not-exist'],
|
||||
disease: DISEASE,
|
||||
startsOn: '2026-10-01',
|
||||
})
|
||||
.expect(404);
|
||||
expect(
|
||||
await prisma.apiaryRestriction.count({
|
||||
where: { apiaryId: { in: apiaryIds() } },
|
||||
}),
|
||||
).toBe(2);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/apiary-restrictions')
|
||||
.set(auth)
|
||||
.send({
|
||||
apiaryIds: [apiaryFreeId],
|
||||
disease: DISEASE,
|
||||
startsOn: '2026-10-05',
|
||||
endsOn: '2026-10-01',
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
const ended = await request(app.getHttpServer())
|
||||
.patch(`/api/v1/apiary-restrictions/${rows[0].id}`)
|
||||
.set(auth)
|
||||
.send({ endsOn: '2026-10-20' })
|
||||
.expect(200);
|
||||
expect((ended.body as RestrictionBody).endsOn).toContain('2026-10-20');
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/apiary-restrictions/${rows[0].id}`)
|
||||
.set(auth)
|
||||
.send({ endsOn: '2026-09-01' })
|
||||
.expect(400);
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/apiary-restrictions/${rows[0].id}`)
|
||||
.set(auth)
|
||||
.send({ endsOn: null })
|
||||
.expect(200);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { entityType: 'ApiaryRestriction', entityId: rows[0].id },
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('a restriction created while a move into that apiary is in flight: the move commits first, the restriction waits (FOR UPDATE vs FOR SHARE)', async () => {
|
||||
let letMoveCommit!: () => void;
|
||||
const commitSignal = new Promise<void>((resolve) => {
|
||||
letMoveCommit = resolve;
|
||||
});
|
||||
let moveHoldsLock!: () => void;
|
||||
const lockHeld = new Promise<void>((resolve) => {
|
||||
moveHoldsLock = resolve;
|
||||
});
|
||||
// What a move into the apiary holds between its lock and its commit.
|
||||
const moveInFlight = prisma.$transaction(
|
||||
async (tx) => {
|
||||
await lockRowsForShare(tx, 'Apiary', [apiaryFreeId]);
|
||||
moveHoldsLock();
|
||||
await commitSignal;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await lockHeld;
|
||||
|
||||
let answered = false;
|
||||
const creating = request(app.getHttpServer())
|
||||
.post('/api/v1/apiary-restrictions')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({
|
||||
apiaryIds: [apiaryFreeId],
|
||||
disease: DISEASE,
|
||||
startsOn: '2026-10-01',
|
||||
})
|
||||
.then((response) => {
|
||||
answered = true;
|
||||
return response;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
expect(answered).toBe(false);
|
||||
|
||||
letMoveCommit();
|
||||
await moveInFlight;
|
||||
expect((await creating).status).toBe(201);
|
||||
});
|
||||
|
||||
it('details (authority, reference, notes, creator) need compliance:read; apiaries:read alone gets the summary', async () => {
|
||||
const restriction = await prisma.apiaryRestriction.create({
|
||||
data: {
|
||||
apiaryId: apiaryRestrictedId,
|
||||
disease: DISEASE,
|
||||
authority: 'Veterinaeramt Geheim',
|
||||
reference: 'AZ-GEHEIM-1',
|
||||
notes: 'interne Notiz',
|
||||
startsOn: dayToDate('2000-01-01'),
|
||||
createdByUserId: userIds[0],
|
||||
},
|
||||
});
|
||||
const paths = [
|
||||
'/api/v1/apiary-restrictions',
|
||||
`/api/v1/apiaries/${apiaryRestrictedId}/restrictions`,
|
||||
];
|
||||
for (const path of paths) {
|
||||
const minimal = await request(app.getHttpServer())
|
||||
.get(path)
|
||||
.set('Authorization', `Bearer ${apiariesOnlyToken}`)
|
||||
.expect(200);
|
||||
const rows = (minimal.body as RestrictionBody[]).filter(
|
||||
(row) => row.id === restriction.id,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(Object.keys(rows[0]).sort()).toEqual([
|
||||
'apiaryId',
|
||||
'disease',
|
||||
'endsOn',
|
||||
'id',
|
||||
'startsOn',
|
||||
]);
|
||||
const text = JSON.stringify(minimal.body);
|
||||
for (const secret of ['Geheim', 'AZ-GEHEIM-1', 'interne Notiz']) {
|
||||
expect(text).not.toContain(secret);
|
||||
}
|
||||
const full = await request(app.getHttpServer())
|
||||
.get(path)
|
||||
.set('Authorization', `Bearer ${readonlyToken}`)
|
||||
.expect(200);
|
||||
const detailed = (full.body as Record<string, unknown>[]).find(
|
||||
(row) => row.id === restriction.id,
|
||||
);
|
||||
expect(detailed).toMatchObject({
|
||||
authority: 'Veterinaeramt Geheim',
|
||||
reference: 'AZ-GEHEIM-1',
|
||||
notes: 'interne Notiz',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('a move and a restriction on the target apiary racing each other end consistently', async () => {
|
||||
for (let round = 0; round < 4; round += 1) {
|
||||
await prisma.apiaryRestriction.deleteMany({
|
||||
where: { apiaryId: { in: apiaryIds() } },
|
||||
});
|
||||
const colonyId = await colonyIn(apiaryFreeId);
|
||||
const [move, created] = await Promise.all([
|
||||
request(app.getHttpServer())
|
||||
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ apiaryId: apiaryOtherId }),
|
||||
request(app.getHttpServer())
|
||||
.post('/api/v1/apiary-restrictions')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({
|
||||
apiaryIds: [apiaryOtherId],
|
||||
disease: DISEASE,
|
||||
startsOn: '2000-01-01',
|
||||
}),
|
||||
]);
|
||||
// Either the move committed first and the restriction followed, or the
|
||||
// restriction was first and the move was refused - never a move that
|
||||
// slipped past a restriction that was already in force.
|
||||
expect(created.status).toBe(201);
|
||||
expect([201, 409]).toContain(move.status);
|
||||
const colony = await prisma.colony.findUniqueOrThrow({
|
||||
where: { id: colonyId },
|
||||
});
|
||||
expect(colony.apiaryId).toBe(
|
||||
move.status === 201 ? apiaryOtherId : apiaryFreeId,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -648,4 +648,443 @@ describe('Treatments (e2e)', () => {
|
||||
403,
|
||||
));
|
||||
});
|
||||
|
||||
describe('treatments from a veterinary medicine lot', () => {
|
||||
const suffix = Date.now();
|
||||
let medicineId: string;
|
||||
let otherMedicineId: string;
|
||||
let lotA: string;
|
||||
let lotB: string;
|
||||
let otherMedicineLot: string;
|
||||
let emptyLot: string;
|
||||
let expiredLot: string;
|
||||
let colonyIds: string[];
|
||||
let restrictedRoleId: string;
|
||||
let restrictedUserId: string;
|
||||
let restrictedToken: string;
|
||||
const restrictedEmail = `treatments-lot-e2e-restricted-${suffix}@example.com`;
|
||||
|
||||
const post = (token: string, path: string, body: object, key?: string) => {
|
||||
const call = request(app.getHttpServer())
|
||||
.post(`/api/v1/treatments${path}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
return (key === undefined ? call : call.set('Idempotency-Key', key)).send(
|
||||
body,
|
||||
);
|
||||
};
|
||||
const patch = (id: string, body: object) =>
|
||||
request(app.getHttpServer())
|
||||
.patch(`/api/v1/treatments/${id}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send(body);
|
||||
const movementsOf = async (lotId: string) =>
|
||||
(
|
||||
await prisma.veterinaryMedicineMovement.findMany({
|
||||
where: { lotId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
).map((movement) => movement.quantity);
|
||||
const restOf = async (lotId: string) => {
|
||||
const lot = await prisma.veterinaryMedicineLot.findUniqueOrThrow({
|
||||
where: { id: lotId },
|
||||
});
|
||||
const sum = (await movementsOf(lotId)).reduce((a, b) => a + b, 0);
|
||||
return Math.round((lot.quantity + sum) * 1000) / 1000;
|
||||
};
|
||||
const lotData = (id: string, extra: object = {}) => ({
|
||||
medicineId: id,
|
||||
purchasedOn: new Date('2026-01-10'),
|
||||
quantity: 100,
|
||||
supplier: 'Apotheke Z, Teststr. 1',
|
||||
purchaseProof: 'Rechnung 42',
|
||||
batchNumber: 'CH-LOT',
|
||||
...extra,
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const medicine = await prisma.veterinaryMedicine.create({
|
||||
data: { name: `Lot-Mittel ${suffix}`, unit: 'ML' },
|
||||
});
|
||||
medicineId = medicine.id;
|
||||
otherMedicineId = (
|
||||
await prisma.veterinaryMedicine.create({
|
||||
data: { name: `Anderes Mittel ${suffix}`, unit: 'ML' },
|
||||
})
|
||||
).id;
|
||||
lotA = (
|
||||
await prisma.veterinaryMedicineLot.create({ data: lotData(medicineId) })
|
||||
).id;
|
||||
lotB = (
|
||||
await prisma.veterinaryMedicineLot.create({ data: lotData(medicineId) })
|
||||
).id;
|
||||
otherMedicineLot = (
|
||||
await prisma.veterinaryMedicineLot.create({
|
||||
data: lotData(otherMedicineId),
|
||||
})
|
||||
).id;
|
||||
emptyLot = (
|
||||
await prisma.veterinaryMedicineLot.create({
|
||||
data: lotData(medicineId, { quantity: 10 }),
|
||||
})
|
||||
).id;
|
||||
expiredLot = (
|
||||
await prisma.veterinaryMedicineLot.create({
|
||||
data: lotData(medicineId, { expiresOn: new Date('2025-01-31') }),
|
||||
})
|
||||
).id;
|
||||
colonyIds = [];
|
||||
for (const code of ['TL-1', 'TL-2', 'TL-3']) {
|
||||
colonyIds.push(
|
||||
(
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `${code}-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
})
|
||||
).id,
|
||||
);
|
||||
}
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `TREATMENT_ONLY_${suffix}`,
|
||||
name: 'Treatment only',
|
||||
permissions: {
|
||||
create: [{ permission: { connect: { key: 'treatments:create' } } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
restrictedRoleId = role.id;
|
||||
restrictedUserId = (
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: restrictedEmail,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: 'Treatment only',
|
||||
role: { connect: { id: role.id } },
|
||||
},
|
||||
})
|
||||
).id;
|
||||
restrictedToken = (await login(app, restrictedEmail, password))
|
||||
.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const treatmentIds = (
|
||||
await prisma.treatment.findMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((treatment) => treatment.id);
|
||||
await prisma.veterinaryMedicineMovement.deleteMany({
|
||||
where: { treatmentId: { in: treatmentIds } },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
aggregateType: 'Treatment',
|
||||
aggregateId: { in: treatmentIds },
|
||||
},
|
||||
});
|
||||
await prisma.treatment.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.veterinaryMedicineLot.deleteMany({
|
||||
where: { medicineId: { in: [medicineId, otherMedicineId] } },
|
||||
});
|
||||
await prisma.veterinaryMedicine.deleteMany({
|
||||
where: { id: { in: [medicineId, otherMedicineId] } },
|
||||
});
|
||||
await prisma.suggestion.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.idempotencyRecord.deleteMany({
|
||||
where: { userId: { in: [beekeeperId, restrictedUserId] } },
|
||||
});
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: restrictedUserId },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({ where: { userId: restrictedUserId } });
|
||||
await prisma.user.deleteMany({ where: { id: restrictedUserId } });
|
||||
await prisma.role.deleteMany({ where: { id: restrictedRoleId } });
|
||||
});
|
||||
|
||||
it('editing dose and lot books only the delta and the movements sum to the new dose', async () => {
|
||||
const created = await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[0],
|
||||
date: '2026-08-01',
|
||||
medicineLotId: lotA,
|
||||
doseQuantity: 30,
|
||||
}).expect(201);
|
||||
const body = created.body as {
|
||||
id: string;
|
||||
product: string;
|
||||
medicineId: string;
|
||||
supplier: string | null;
|
||||
batchNumber: string | null;
|
||||
};
|
||||
expect(body).toMatchObject({
|
||||
product: `Lot-Mittel ${suffix}`,
|
||||
medicineId,
|
||||
supplier: null,
|
||||
batchNumber: null,
|
||||
});
|
||||
expect(await movementsOf(lotA)).toEqual([-30]);
|
||||
|
||||
await patch(body.id, { doseQuantity: 20 }).expect(200);
|
||||
expect(await movementsOf(lotA)).toEqual([-30, 10]);
|
||||
expect(await restOf(lotA)).toBe(80);
|
||||
|
||||
await patch(body.id, { notes: 'nur Notiz' }).expect(200);
|
||||
expect(await movementsOf(lotA)).toEqual([-30, 10]);
|
||||
|
||||
await patch(body.id, { medicineLotId: lotB }).expect(200);
|
||||
expect(await movementsOf(lotA)).toEqual([-30, 10, 20]);
|
||||
expect(await movementsOf(lotB)).toEqual([-20]);
|
||||
expect(await restOf(lotA)).toBe(100);
|
||||
expect(await restOf(lotB)).toBe(80);
|
||||
|
||||
await patch(body.id, { doseQuantity: null }).expect(200);
|
||||
expect(await restOf(lotB)).toBe(100);
|
||||
const stored = await prisma.treatment.findUniqueOrThrow({
|
||||
where: { id: body.id },
|
||||
});
|
||||
expect(stored.medicineLotId).toBe(lotB);
|
||||
expect(stored.doseQuantity).toBeNull();
|
||||
|
||||
// Dose again: books -15 on lot B, the counter-bookings stay.
|
||||
await patch(body.id, { doseQuantity: 15 }).expect(200);
|
||||
expect(await restOf(lotB)).toBe(85);
|
||||
});
|
||||
|
||||
it('rejects the invalid combinations with 400 and books nothing', async () => {
|
||||
const before = await movementsOf(lotA);
|
||||
const base = { colonyId: colonyIds[0], date: '2026-08-02' };
|
||||
await post(beekeeperToken, '', {
|
||||
...base,
|
||||
medicineLotId: lotA,
|
||||
supplier: 'Eigener Text',
|
||||
}).expect(400);
|
||||
await post(beekeeperToken, '', {
|
||||
...base,
|
||||
medicineId: medicineId,
|
||||
medicineLotId: otherMedicineLot,
|
||||
}).expect(400);
|
||||
await post(beekeeperToken, '', {
|
||||
...base,
|
||||
medicineLotId: lotA,
|
||||
doseQuantity: 0,
|
||||
}).expect(400);
|
||||
await post(beekeeperToken, '', {
|
||||
...base,
|
||||
medicineId,
|
||||
product: 'Etwas anderes',
|
||||
}).expect(400);
|
||||
await post(beekeeperToken, '', { ...base }).expect(400);
|
||||
expect(await movementsOf(lotA)).toEqual(before);
|
||||
});
|
||||
|
||||
it('rejects medicineId null while the treatment has a lot', async () => {
|
||||
const created = await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[1],
|
||||
date: '2026-08-03',
|
||||
medicineLotId: lotA,
|
||||
}).expect(201);
|
||||
await patch((created.body as { id: string }).id, {
|
||||
medicineId: null,
|
||||
}).expect(400);
|
||||
});
|
||||
|
||||
it('a repeated bulk request with the same Idempotency-Key books once', async () => {
|
||||
const key = `treatments-lot-bulk-${suffix}`;
|
||||
const body = {
|
||||
date: '2026-08-05',
|
||||
medicineLotId: lotB,
|
||||
doseQuantity: 5,
|
||||
colonyIds: [colonyIds[1], colonyIds[2]],
|
||||
};
|
||||
const restBefore = await restOf(lotB);
|
||||
|
||||
const first = await post(beekeeperToken, '/bulk', body, key).expect(201);
|
||||
const replay = await post(beekeeperToken, '/bulk', body, key).expect(201);
|
||||
|
||||
expect((replay.body as { bulkId: string }).bulkId).toBe(
|
||||
(first.body as { bulkId: string }).bulkId,
|
||||
);
|
||||
expect(await restOf(lotB)).toBe(restBefore - 10);
|
||||
expect(
|
||||
await prisma.veterinaryMedicineMovement.count({
|
||||
where: {
|
||||
lotId: lotB,
|
||||
treatment: { colonyId: { in: [colonyIds[1], colonyIds[2]] } },
|
||||
},
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('a bulk request with an unknown colony books nothing', async () => {
|
||||
const restBefore = await restOf(lotB);
|
||||
await post(beekeeperToken, '/bulk', {
|
||||
date: '2026-08-06',
|
||||
medicineLotId: lotB,
|
||||
doseQuantity: 5,
|
||||
colonyIds: [colonyIds[1], 'does-not-exist'],
|
||||
}).expect(400);
|
||||
expect(await restOf(lotB)).toBe(restBefore);
|
||||
});
|
||||
|
||||
it('a treatment from an empty lot is stored and the rest turns negative', async () => {
|
||||
await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[2],
|
||||
date: '2026-08-07',
|
||||
medicineLotId: emptyLot,
|
||||
doseQuantity: 12,
|
||||
}).expect(201);
|
||||
|
||||
expect(await restOf(emptyLot)).toBe(-2);
|
||||
});
|
||||
|
||||
it('a treatment after the lot expired is stored', async () => {
|
||||
await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[2],
|
||||
date: '2026-08-08',
|
||||
medicineLotId: expiredLot,
|
||||
doseQuantity: 3,
|
||||
}).expect(201);
|
||||
|
||||
expect(await restOf(expiredLot)).toBe(97);
|
||||
});
|
||||
|
||||
it('a user with treatments:create but without veterinary-medicines:read can book from a lot', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/veterinary-medicine-lots')
|
||||
.set('Authorization', `Bearer ${restrictedToken}`)
|
||||
.expect(403);
|
||||
|
||||
await post(restrictedToken, '', {
|
||||
colonyId: colonyIds[0],
|
||||
date: '2026-08-09',
|
||||
medicineLotId: lotA,
|
||||
doseQuantity: 1,
|
||||
}).expect(201);
|
||||
|
||||
expect((await movementsOf(lotA)).at(-1)).toBe(-1);
|
||||
});
|
||||
|
||||
it('a long edit sequence keeps every lot and the treatment total consistent', async () => {
|
||||
const [lotX, lotY, lotZ] = await Promise.all(
|
||||
[1, 2, 3].map(
|
||||
async () =>
|
||||
(
|
||||
await prisma.veterinaryMedicineLot.create({
|
||||
data: lotData(medicineId),
|
||||
})
|
||||
).id,
|
||||
),
|
||||
);
|
||||
const lots = [lotX, lotY, lotZ];
|
||||
const created = await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[0],
|
||||
date: '2026-08-12',
|
||||
medicineLotId: lotX,
|
||||
doseQuantity: 10,
|
||||
}).expect(201);
|
||||
const id = (created.body as { id: string }).id;
|
||||
|
||||
// After every step: the movements per lot sum to -dose on the current
|
||||
// lot and to 0 on all others; over all lots they sum to -dose (0 when
|
||||
// the lot is null).
|
||||
const expectState = async (
|
||||
currentLot: string | null,
|
||||
dose: number | null,
|
||||
) => {
|
||||
const wanted = currentLot !== null && dose !== null ? dose : 0;
|
||||
let total = 0;
|
||||
for (const lot of lots) {
|
||||
const sum = Math.round(
|
||||
(await movementsOf(lot)).reduce((a, b) => a + b, 0) * 1000,
|
||||
);
|
||||
expect(sum / 1000).toBe(lot === currentLot ? 0 - wanted : 0);
|
||||
total += sum;
|
||||
}
|
||||
expect(total / 1000).toBe(0 - wanted);
|
||||
const stored = await prisma.treatment.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
expect(stored.medicineLotId).toBe(currentLot);
|
||||
expect(stored.doseQuantity).toBe(dose);
|
||||
};
|
||||
|
||||
await expectState(lotX, 10);
|
||||
await patch(id, { doseQuantity: 12.5 }).expect(200);
|
||||
await expectState(lotX, 12.5);
|
||||
await patch(id, { medicineLotId: lotY }).expect(200);
|
||||
await expectState(lotY, 12.5);
|
||||
await patch(id, { medicineLotId: lotZ }).expect(200);
|
||||
await expectState(lotZ, 12.5);
|
||||
await patch(id, { medicineLotId: null }).expect(200);
|
||||
await expectState(null, 12.5);
|
||||
await patch(id, { medicineLotId: lotX }).expect(200);
|
||||
await expectState(lotX, 12.5);
|
||||
await patch(id, { doseQuantity: 4 }).expect(200);
|
||||
await expectState(lotX, 4);
|
||||
await patch(id, { doseQuantity: null }).expect(200);
|
||||
await expectState(lotX, null);
|
||||
});
|
||||
|
||||
it('a repeated single POST with the same Idempotency-Key books exactly one movement', async () => {
|
||||
const key = `treatments-lot-single-${suffix}`;
|
||||
const body = {
|
||||
colonyId: colonyIds[1],
|
||||
date: '2026-08-13',
|
||||
medicineLotId: expiredLot,
|
||||
doseQuantity: 1.5,
|
||||
};
|
||||
const restBefore = await restOf(expiredLot);
|
||||
|
||||
const first = await post(beekeeperToken, '', body, key).expect(201);
|
||||
const replay = await post(beekeeperToken, '', body, key).expect(201);
|
||||
|
||||
expect((replay.body as { id: string }).id).toBe(
|
||||
(first.body as { id: string }).id,
|
||||
);
|
||||
expect(await restOf(expiredLot)).toBe(restBefore - 1.5);
|
||||
expect(
|
||||
await prisma.veterinaryMedicineMovement.count({
|
||||
where: { treatmentId: (first.body as { id: string }).id },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('re-filing the treatment to another colony leaves the stock untouched', async () => {
|
||||
const created = await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[0],
|
||||
date: '2026-08-10',
|
||||
medicineLotId: lotA,
|
||||
doseQuantity: 2,
|
||||
}).expect(201);
|
||||
const restBefore = await restOf(lotA);
|
||||
|
||||
await patch((created.body as { id: string }).id, {
|
||||
colonyId: colonyIds[1],
|
||||
}).expect(200);
|
||||
|
||||
expect(await restOf(lotA)).toBe(restBefore);
|
||||
});
|
||||
|
||||
it('corrects the apiary of a treatment and answers 404 for an unknown one', async () => {
|
||||
const created = await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[0],
|
||||
date: '2026-08-11',
|
||||
product: 'Ameisensäure',
|
||||
}).expect(201);
|
||||
const id = (created.body as { id: string }).id;
|
||||
|
||||
await patch(id, { apiaryId: 'does-not-exist' }).expect(404);
|
||||
await patch(id, { apiaryId: null }).expect(400);
|
||||
const fixed = await patch(id, { apiaryId }).expect(200);
|
||||
expect((fixed.body as { apiaryId: string }).apiaryId).toBe(apiaryId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { inflateSync } from 'node:zlib';
|
||||
|
||||
/**
|
||||
* The text a pdfkit document prints, for assertions in tests: inflates the
|
||||
* content streams and joins the strings of every text-showing operator
|
||||
* (hex strings of the standard fonts, one line per operator). Not a PDF
|
||||
* parser - it reads what pdfkit writes with the built-in Helvetica.
|
||||
*/
|
||||
export function pdfText(pdf: Buffer): string {
|
||||
const source = pdf.toString('latin1');
|
||||
const lines: string[] = [];
|
||||
const streams = source.matchAll(/stream\r?\n([\s\S]*?)\r?\nendstream/g);
|
||||
for (const [, raw] of streams) {
|
||||
let content: string;
|
||||
try {
|
||||
content = inflateSync(Buffer.from(raw, 'latin1')).toString('latin1');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const operator of content.matchAll(
|
||||
/\[([^\]]*)\]\s*TJ|<([0-9a-fA-F]+)>\s*Tj/g,
|
||||
)) {
|
||||
const parts = operator[1] ?? `<${operator[2]}>`;
|
||||
const text = [...parts.matchAll(/<([0-9a-fA-F]*)>/g)]
|
||||
.map(([, hex]) => Buffer.from(hex, 'hex').toString('latin1'))
|
||||
.join('');
|
||||
lines.push(text);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in new issue
Block a user