- 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]>
1091 lines
35 KiB
TypeScript
1091 lines
35 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|