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

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

1091 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface TreatmentBody {
id: string;
colonyId: string;
startDate: string | null;
endDate: string | null;
}
interface WithdrawalStatusBody {
colonyId: string;
activeUntil: string;
lastBlockedDay: string;
withdrawalText: string;
product: string;
}
interface BulkTreatmentBody {
bulkId: string;
records: (TreatmentBody & { apiaryId: string; batchNumber: string })[];
}
describe('Treatments (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `treatments-e2e-beekeeper-${Date.now()}@example.com`;
const staffEmail = `treatments-e2e-staff-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let staffId: string;
let beekeeperToken: string;
let staffToken: string;
let apiaryId: string;
let colonyId: string;
let treatmentId: 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;
const staff = await prisma.user.create({
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, 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;
});
afterAll(async () => {
const treatmentIds = (
await prisma.treatment.findMany({
where: { colonyId },
select: { id: true },
})
).map((treatment) => treatment.id);
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds } },
});
await prisma.treatment.deleteMany({ where: { colonyId } });
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [beekeeperId, staffId] } },
});
await app.close();
});
it('rejects create for a STAFF user', () => {
return request(app.getHttpServer())
.post('/api/v1/treatments')
.set('Authorization', `Bearer ${staffToken}`)
.send({
colonyId,
date: new Date().toISOString(),
product: 'Ameisensäure',
})
.expect(403);
});
it('lets a BEEKEEPER user record a treatment', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/treatments')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
colonyId,
date: new Date().toISOString(),
product: 'Ameisensäure',
amount: '15 ml',
})
.expect(201);
treatmentId = (response.body as TreatmentBody).id;
expect(treatmentId).toEqual(expect.any(String));
});
it('wrote the audit entry and TreatmentRecorded together with the treatment', async () => {
expect(
await prisma.auditLog.count({
where: {
entityType: 'Treatment',
entityId: treatmentId,
action: 'CREATE',
userId: beekeeperId,
},
}),
).toBe(1);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Treatment', aggregateId: treatmentId },
});
expect(events).toHaveLength(1);
expect(events[0]).toEqual(
expect.objectContaining({
type: 'TreatmentRecorded',
actorUserId: beekeeperId,
payload: { colonyId },
}),
);
});
it('lists treatments for the colony', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}/treatments`)
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
const treatments = response.body as TreatmentBody[];
expect(treatments.some((entry) => entry.id === treatmentId)).toBe(true);
});
it('clears start and end date on null and audits the edit', async () => {
const correctionWhere = {
aggregateType: 'Treatment',
aggregateId: treatmentId,
type: 'TreatmentCorrected',
} as const;
const correctionsBefore = await prisma.domainEvent.count({
where: correctionWhere,
});
await request(app.getHttpServer())
.patch(`/api/v1/treatments/${treatmentId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ startDate: '2026-07-01', endDate: '2026-07-14' })
.expect(200);
const response = await request(app.getHttpServer())
.patch(`/api/v1/treatments/${treatmentId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ startDate: null, endDate: null })
.expect(200);
const treatment = response.body as TreatmentBody;
expect(treatment.startDate).toBeNull();
expect(treatment.endDate).toBeNull();
const auditEntry = await prisma.auditLog.findFirst({
where: {
entityType: 'Treatment',
entityId: treatmentId,
action: 'UPDATE',
userId: beekeeperId,
},
orderBy: { timestamp: 'desc' },
});
expect(auditEntry?.afterData).toEqual(
expect.objectContaining({ startDate: null, endDate: null }),
);
// Two edits in this test, one event each.
const corrections = await prisma.domainEvent.findMany({
where: correctionWhere,
});
expect(corrections).toHaveLength(correctionsBefore + 2);
expect(
corrections.every(
(event) =>
(event.payload as { colonyId: string }).colonyId === colonyId,
),
).toBe(true);
});
it.each(['date', 'product'])('rejects null for the required %s', (field) =>
request(app.getHttpServer())
.patch(`/api/v1/treatments/${treatmentId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ [field]: null })
.expect(400),
);
describe('withdrawal status for a harvest date', () => {
async function withdrawalStatusAt(at?: string) {
const response = await request(app.getHttpServer())
.get('/api/v1/treatments/withdrawal-status')
.query(
at === undefined
? { colonyIds: colonyId }
: { colonyIds: colonyId, at },
)
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
return response.body as WithdrawalStatusBody[];
}
beforeAll(async () => {
// Ended 2026-07-01 with 14 days withdrawal: harvestable from 2026-07-15.
await request(app.getHttpServer())
.post('/api/v1/treatments')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
colonyId,
date: '2026-06-20',
endDate: '2026-07-01',
product: 'Oxalsäure',
withdrawalPeriodDays: 14,
})
.expect(201);
});
it('reports the window as active on a harvest date inside it', async () => {
await expect(withdrawalStatusAt('2026-07-10')).resolves.toEqual([
{
colonyId,
activeUntil: '2026-07-15T00:00:00.000Z',
lastBlockedDay: '2026-07-14',
withdrawalText:
'bis einschließlich 14.07. – ab 15.07. wieder erntefähig',
product: 'Oxalsäure',
},
]);
});
it('accepts a full ISO timestamp', async () => {
await expect(
withdrawalStatusAt('2026-07-14T23:59:59+02:00'),
).resolves.toHaveLength(1);
});
it('reports nothing once the window has ended by the harvest date', async () => {
await expect(withdrawalStatusAt('2026-07-15')).resolves.toEqual([]);
});
it('ignores a treatment that starts after the harvest date', async () => {
await expect(withdrawalStatusAt('2026-06-01')).resolves.toEqual([]);
});
it('evaluates against now without a date', async () => {
await expect(withdrawalStatusAt()).resolves.toEqual([]);
});
it('rejects asking for more than 200 colonies at once', async () => {
const ids = Array.from(
{ length: 201 },
(_, index) => `cl${index.toString().padStart(23, '0')}`,
);
const response = await request(app.getHttpServer())
.get('/api/v1/treatments/withdrawal-status')
.query({ colonyIds: ids.join(',') })
.set('Authorization', `Bearer ${staffToken}`)
.expect(400);
expect(JSON.stringify(response.body)).toContain(
'colonyIds darf höchstens 200 Volk-IDs enthalten',
);
});
it.each(["colony'a", 'colony;drop', 'c'.repeat(65)])(
'rejects the invalid colony id %s',
(badId) =>
request(app.getHttpServer())
.get('/api/v1/treatments/withdrawal-status')
.query({ colonyIds: `${colonyId},${badId}` })
.set('Authorization', `Bearer ${staffToken}`)
.expect(400),
);
it.each(['gestern', '2026-02-30', '20260715', '2026-07-15T10Z', '2026'])(
'rejects the invalid date %s',
(at) =>
request(app.getHttpServer())
.get('/api/v1/treatments/withdrawal-status')
.query({ colonyIds: colonyId, at })
.set('Authorization', `Bearer ${staffToken}`)
.expect(400),
);
});
describe('GET /treatments/last-by-product', () => {
const productName = `Oxuvar-${Date.now()}`;
const lastByProduct = (token: string, product?: string) =>
request(app.getHttpServer())
.get('/api/v1/treatments/last-by-product')
.query(product === undefined ? {} : { product })
.set('Authorization', `Bearer ${token}`);
beforeAll(async () => {
await prisma.treatment.createMany({
data: [
{
colonyId,
userId: beekeeperId,
date: new Date('2026-05-01T00:00:00.000Z'),
product: productName,
supplier: 'Alt GmbH',
},
{
colonyId,
userId: beekeeperId,
date: new Date('2026-06-01T00:00:00.000Z'),
product: productName,
activeIngredient: 'Oxalsäure',
withdrawalPeriodDays: 0,
supplier: 'Neu GmbH',
purchaseProof: 'RE-2026-17',
prescribingVet: 'Dr. Imker',
},
{
colonyId,
userId: staffId,
date: new Date('2026-07-01T00:00:00.000Z'),
product: productName,
supplier: 'Fremd GmbH',
},
],
});
});
it("returns the caller's newest treatment with that product, case-insensitive", async () => {
const response = await lastByProduct(
beekeeperToken,
` ${productName.toLowerCase()} `,
).expect(200);
const newest = await prisma.treatment.findFirstOrThrow({
where: {
userId: beekeeperId,
product: productName,
supplier: 'Neu GmbH',
},
select: { id: true },
});
expect(response.body).toEqual({
treatment: {
sourceTreatmentId: newest.id,
sourceDate: '2026-06-01T00:00:00.000Z',
product: productName,
activeIngredient: 'Oxalsäure',
withdrawalPeriodDays: 0,
supplier: 'Neu GmbH',
purchaseProof: 'RE-2026-17',
prescribingVet: 'Dr. Imker',
},
});
});
it("never returns another user's treatment", async () => {
const response = await lastByProduct(beekeeperToken, productName).expect(
200,
);
expect(JSON.stringify(response.body)).not.toContain('Fremd GmbH');
});
it('returns { treatment: null } without a match', async () => {
const response = await lastByProduct(
beekeeperToken,
`Unbekannt-${Date.now()}`,
).expect(200);
expect(response.body).toEqual({ treatment: null });
});
it('rejects a request without product', () =>
lastByProduct(beekeeperToken).expect(400));
it('rejects an empty product', () =>
lastByProduct(beekeeperToken, ' ').expect(400));
it('rejects a role without treatments:create', () =>
lastByProduct(staffToken, productName).expect(403));
});
describe('POST /treatments/bulk', () => {
const suffix = Date.now();
const shared = {
date: '2026-08-15',
product: 'Oxalsäure',
batchNumber: 'CH-1',
withdrawalPeriodDays: 0,
};
let secondApiaryId: string;
// Two colonies at the first apiary, one at the second.
let bulkColonyIds: string[];
let endedColonyId: string;
const postBulk = (token: string, body: object, idempotencyKey?: string) => {
const call = request(app.getHttpServer())
.post('/api/v1/treatments/bulk')
.set('Authorization', `Bearer ${token}`);
return (
idempotencyKey === undefined
? call
: call.set('Idempotency-Key', idempotencyKey)
).send(body);
};
const storedTreatments = () =>
prisma.treatment.count({
where: { colonyId: { in: [...bulkColonyIds, endedColonyId] } },
});
beforeAll(async () => {
const secondApiary = await prisma.apiary.create({
data: { name: `Stand B ${suffix}` },
});
secondApiaryId = secondApiary.id;
const colonyAt = async (
code: string,
atApiaryId: string,
status: 'ACTIVE' | 'WEAK' | 'DISSOLVED' = 'ACTIVE',
) =>
(
await prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId: atApiaryId,
colonyType: 'PRODUCTION_COLONY',
status,
},
})
).id;
bulkColonyIds = [
await colonyAt('TB-1', apiaryId),
await colonyAt('TB-2', apiaryId, 'WEAK'),
await colonyAt('TB-3', secondApiaryId),
];
endedColonyId = await colonyAt('TB-X', apiaryId, 'DISSOLVED');
});
afterAll(async () => {
const colonyIds = [...bulkColonyIds, endedColonyId];
const treatmentIds = (
await prisma.treatment.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((treatment) => treatment.id);
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'Treatment',
aggregateId: { in: treatmentIds },
},
});
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: secondApiaryId } });
await prisma.idempotencyRecord.deleteMany({
where: { userId: beekeeperId },
});
});
it("bulk creates one treatment per colony with the shared fields and each colony's apiary", async () => {
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: bulkColonyIds,
}).expect(201);
const body = response.body as BulkTreatmentBody;
expect(body.bulkId).toEqual(expect.any(String));
expect(body.records).toHaveLength(3);
expect(body.records.map((record) => record.colonyId)).toEqual(
bulkColonyIds,
);
expect(
body.records.every((record) => record.batchNumber === 'CH-1'),
).toBe(true);
expect(body.records.map((record) => record.apiaryId)).toEqual([
apiaryId,
apiaryId,
secondApiaryId,
]);
});
it('bulk writes one TreatmentRecorded event and one audit entry per record', async () => {
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: bulkColonyIds,
}).expect(201);
const { bulkId, records } = response.body as BulkTreatmentBody;
const ofThisBulk = {
type: 'TreatmentRecorded',
payload: { path: ['bulkId'], equals: bulkId },
};
expect(await prisma.domainEvent.count({ where: ofThisBulk })).toBe(3);
const events = await prisma.domainEvent.findMany({ where: ofThisBulk });
expect(
events
.map((event) => ({
aggregateId: event.aggregateId,
actorUserId: event.actorUserId,
payload: event.payload,
}))
.sort((a, b) => a.aggregateId.localeCompare(b.aggregateId)),
).toEqual(
records
.map((record) => ({
aggregateId: record.id,
actorUserId: beekeeperId,
payload: { colonyId: record.colonyId, bulkId },
}))
.sort((a, b) => a.aggregateId.localeCompare(b.aggregateId)),
);
expect(
await prisma.auditLog.count({
where: {
entityType: 'Treatment',
action: 'CREATE',
userId: beekeeperId,
entityId: { in: records.map((record) => record.id) },
},
}),
).toBe(3);
});
it('bulk rejects the whole batch when one colony has ended and names it', async () => {
const before = await storedTreatments();
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: [...bulkColonyIds, endedColonyId],
}).expect(400);
expect(
(response.body as { invalidColonyIds: string[] }).invalidColonyIds,
).toEqual([endedColonyId]);
expect(await storedTreatments()).toBe(before);
});
it.each([
[
'an unknown id',
() => [bulkColonyIds[0], `unknown-${suffix}`],
'Einige Völker sind nicht (mehr) erfassbar',
],
['no id', () => [], 'colonyIds braucht mindestens ein Volk'],
[
'101 ids',
() => [
...bulkColonyIds,
...Array.from({ length: 98 }, (_, index) => `cl-${suffix}-${index}`),
],
'colonyIds darf höchstens 100 Völker enthalten',
],
[
'a duplicate id',
() => [bulkColonyIds[0], bulkColonyIds[0]],
'colonyIds enthält ein Volk doppelt',
],
[
'an empty id',
() => [bulkColonyIds[0], ''],
'colonyIds enthält eine leere Volk-ID',
],
])(
'bulk rejects unknown ids, 0 or 101 ids and duplicates: %s',
async (_label, colonyIds, message) => {
const before = await storedTreatments();
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: colonyIds(),
}).expect(400);
expect(JSON.stringify(response.body)).toContain(message);
expect(await storedTreatments()).toBe(before);
},
);
it('bulk names an unknown id like an ended colony', async () => {
const unknownId = `unknown-${suffix}`;
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: [bulkColonyIds[0], unknownId],
}).expect(400);
expect(
(response.body as { invalidColonyIds: string[] }).invalidColonyIds,
).toEqual([unknownId]);
});
it('bulk replays with the same Idempotency-Key', async () => {
const key = `treatments-bulk-${suffix}`;
const body = { ...shared, colonyIds: bulkColonyIds };
const before = await storedTreatments();
const first = await postBulk(beekeeperToken, body, key).expect(201);
const replay = await postBulk(beekeeperToken, body, key).expect(201);
expect((replay.body as BulkTreatmentBody).bulkId).toBe(
(first.body as BulkTreatmentBody).bulkId,
);
expect(await storedTreatments()).toBe(before + bulkColonyIds.length);
});
it('bulk rejects a role without treatments:create', () =>
postBulk(staffToken, { ...shared, colonyIds: bulkColonyIds }).expect(
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);
});
});
});