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; 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); }); }); });