import { randomUUID } from 'node:crypto'; 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 MedicineBody { id: string; name: string; unit: string; active: boolean; totalRemaining: number; nextExpiry: string | null; lotCount: number; lots?: LotBody[]; } interface MovementBody { id: string; type: string; quantity: number; treatmentId: string | null; reason: string | null; } interface LotBody { id: string; medicineId: string; medicineName: string; unit: string; quantity: number; remaining: number; movementCount: number; batchNumber: string | null; expiresOn: string | null; movements?: MovementBody[]; } interface SuggestionBody { name: string; treatmentCount: number; latest: { activeIngredient: string | null; withdrawalPeriodDays: number | null; method: string | null; amount: string | null; }; } describe('Veterinary medicines (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}`; const password = 'SuperSecret123!'; const tokens: Record = {}; const medicineIds: string[] = []; let colonyId: string; let apiaryId: string; let userId: string; const server = () => app.getHttpServer(); const auth = (role: string) => ({ Authorization: `Bearer ${tokens[role]}` }); async function createMedicine( name: string, extra: Record = {}, ): Promise { const response = await request(server()) .post('/api/v1/veterinary-medicines') .set(auth('STAFF')) .send({ name: `${name}-${suffix}`, unit: 'ML', ...extra }) .expect(201); const body = response.body as MedicineBody; medicineIds.push(body.id); return body; } async function createLot( medicineId: string, quantity = 10, extra: Record = {}, ): Promise { const response = await request(server()) .post('/api/v1/veterinary-medicine-lots') .set(auth('STAFF')) .send({ medicineId, purchasedOn: '2026-08-01', quantity, ...extra }) .expect(201); return response.body as LotBody; } async function lotOf(id: string): Promise { const response = await request(server()) .get(`/api/v1/veterinary-medicine-lots/${id}`) .set(auth('READONLY')) .expect(200); return response.body as LotBody; } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); for (const role of ['STAFF', 'READONLY', 'API_CLIENT']) { const email = `vetmed-e2e-${role.toLowerCase()}-${suffix}@example.com`; const user = await prisma.user.create({ data: { email, passwordHash, name: role, role: { connect: { key: role } }, }, }); if (role === 'STAFF') { userId = user.id; } tokens[role] = (await login(app, email, password)).accessToken; } const apiary = await prisma.apiary.create({ data: { name: `VetMed Stand ${suffix}` }, }); apiaryId = apiary.id; const colony = await prisma.colony.create({ data: { code: `VM-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyId = colony.id; }); afterAll(async () => { const lots = await prisma.veterinaryMedicineLot.findMany({ where: { medicineId: { in: medicineIds } }, select: { id: true }, }); const lotIds = lots.map((lot) => lot.id); await prisma.veterinaryMedicineMovement.deleteMany({ where: { lotId: { in: lotIds } }, }); await prisma.veterinaryMedicineLot.deleteMany({ where: { id: { in: lotIds } }, }); await prisma.veterinaryMedicine.deleteMany({ where: { id: { in: medicineIds } }, }); await prisma.treatment.deleteMany({ where: { colonyId } }); await app.close(); }); it('lot -> rest: a correction books the delta and the rest follows', async () => { const medicine = await createMedicine('Rest'); const lot = await createLot(medicine.id, 10, { batchNumber: 'CH-REST' }); expect(lot).toMatchObject({ remaining: 10, medicineName: `Rest-${suffix}`, unit: 'ML', movementCount: 0, }); const afterCorrection = await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'CORRECTION', actualRemaining: 6.5 }) .expect(201); const detail = afterCorrection.body as LotBody; expect(detail.remaining).toBe(6.5); expect(detail.movements).toEqual([ expect.objectContaining({ type: 'CORRECTION', quantity: -3.5, treatmentId: null, }) as MovementBody, ]); const catalogue = await request(server()) .get(`/api/v1/veterinary-medicines/${medicine.id}`) .set(auth('READONLY')) .expect(200); expect(catalogue.body).toMatchObject({ lotCount: 1, totalRemaining: 6.5, lots: [expect.objectContaining({ id: lot.id, remaining: 6.5 })], }); }); it('a correction to the same rest books nothing', async () => { const medicine = await createMedicine('NoDelta'); const lot = await createLot(medicine.id, 4); const response = await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'CORRECTION', actualRemaining: 4 }) .expect(201); expect((response.body as LotBody).movements).toEqual([]); }); it('a disposal books the negative quantity and may drive the rest negative', async () => { const medicine = await createMedicine('Disposal'); const lot = await createLot(medicine.id, 10); await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'DISPOSAL', quantity: 15, reason: 'Abgelaufen' }) .expect(201); const detail = await lotOf(lot.id); expect(detail.remaining).toBe(-5); expect(detail.movements?.[0]).toMatchObject({ type: 'DISPOSAL', quantity: -15, reason: 'Abgelaufen', }); const catalogue = await request(server()) .get(`/api/v1/veterinary-medicines?active=true`) .set(auth('READONLY')) .expect(200); expect( (catalogue.body as MedicineBody[]).find((m) => m.id === medicine.id), ).toMatchObject({ totalRemaining: -5 }); }); it('a disposal without a reason is a 400', async () => { const medicine = await createMedicine('NoReason'); const lot = await createLot(medicine.id, 10); await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'DISPOSAL', quantity: 1 }) .expect(400); }); it('two parallel inventories on one lot: the second computes with the new rest', async () => { const medicine = await createMedicine('Parallel'); const lot = await createLot(medicine.id, 10); const responses = await Promise.all( [4, 7].map((actualRemaining) => request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'CORRECTION', actualRemaining }), ), ); expect(responses.map((response) => response.status)).toEqual([201, 201]); const detail = await lotOf(lot.id); // Both ran against the rest the other left behind: the deltas are // {-6, +3} or {-3, -3}, never {-6, -3} (both against 10). expect([4, 7]).toContain(detail.remaining); const deltas = (detail.movements ?? []).map((m) => m.quantity).sort(); expect(deltas).toEqual(detail.remaining === 7 ? [-6, 3] : [-3, -3]); }); it('three parallel inventories end on the target of the last one booked', async () => { const medicine = await createMedicine('Parallel3'); const lot = await createLot(medicine.id, 10); const targets = [2, 5, 8]; const responses = await Promise.all( targets.map((actualRemaining) => request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'CORRECTION', actualRemaining }), ), ); expect(responses.map((response) => response.status)).toEqual([ 201, 201, 201, ]); const detail = await lotOf(lot.id); const rows = await prisma.veterinaryMedicineMovement.findMany({ where: { lotId: lot.id }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], }); // Replaying the book in booking order: every delta started from the rest // the one before left, so each step ends on one of the targets and the // last one is the final rest. let rest = 10; const reached = rows.map((row) => { rest = Math.round((rest + row.quantity) * 1000) / 1000; return rest; }); expect(reached.every((value) => targets.includes(value))).toBe(true); expect(detail.remaining).toBe(reached[reached.length - 1]); }); it('rejects changing the unit once a lot exists with 409, other fields stay editable', async () => { const medicine = await createMedicine('Unit'); await createLot(medicine.id, 5); await request(server()) .patch(`/api/v1/veterinary-medicines/${medicine.id}`) .set(auth('STAFF')) .send({ unit: 'G' }) .expect(409); const renamed = await request(server()) .patch(`/api/v1/veterinary-medicines/${medicine.id}`) .set(auth('STAFF')) .send({ notes: 'Kühl lagern', withdrawalPeriodDays: 0 }) .expect(200); expect(renamed.body).toMatchObject({ notes: 'Kühl lagern', withdrawalPeriodDays: 0, unit: 'ML', }); }); it('changes the unit of a medicine without lots and clears a field with null', async () => { const medicine = await createMedicine('Free', { notes: 'x' }); const response = await request(server()) .patch(`/api/v1/veterinary-medicines/${medicine.id}`) .set(auth('STAFF')) .send({ unit: 'PIECE', notes: null }) .expect(200); expect(response.body).toMatchObject({ unit: 'PIECE', notes: null }); }); it('rejects changing the medicine of a lot once a movement exists with 409', async () => { const first = await createMedicine('Move-A'); const second = await createMedicine('Move-B'); const lot = await createLot(first.id, 5); const free = await createLot(first.id, 5); await request(server()) .patch(`/api/v1/veterinary-medicine-lots/${free.id}`) .set(auth('STAFF')) .send({ medicineId: second.id }) .expect(200); await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'CORRECTION', actualRemaining: 3 }) .expect(201); await request(server()) .patch(`/api/v1/veterinary-medicine-lots/${lot.id}`) .set(auth('STAFF')) .send({ medicineId: second.id }) .expect(409); await request(server()) .patch(`/api/v1/veterinary-medicine-lots/${lot.id}`) .set(auth('STAFF')) .send({ supplier: 'Imkerei Muster', expiresOn: null }) .expect(200); }); it('selectable lots come oldest expiry first and leave out an empty lot', async () => { const medicine = await createMedicine('Fifo'); const late = await createLot(medicine.id, 5, { expiresOn: '2028-01-01' }); const early = await createLot(medicine.id, 5, { expiresOn: '2027-01-01' }); const empty = await createLot(medicine.id, 5, { expiresOn: '2026-12-01' }); await request(server()) .post(`/api/v1/veterinary-medicine-lots/${empty.id}/movements`) .set(auth('STAFF')) .send({ type: 'CORRECTION', actualRemaining: 0 }) .expect(201); const response = await request(server()) .get( `/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}&selectable=true`, ) .set(auth('READONLY')) .expect(200); expect((response.body as LotBody[]).map((lot) => lot.id)).toEqual([ early.id, late.id, ]); const all = await request(server()) .get(`/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}`) .set(auth('READONLY')) .expect(200); expect(all.body as LotBody[]).toHaveLength(3); }); it('selectable leaves out the lots of an inactive medicine, includeLotId keeps one', async () => { const medicine = await createMedicine('Inactive'); const lot = await createLot(medicine.id, 5); const emptyLot = await createLot(medicine.id, 5); await request(server()) .post(`/api/v1/veterinary-medicine-lots/${emptyLot.id}/movements`) .set(auth('STAFF')) .send({ type: 'CORRECTION', actualRemaining: 0 }) .expect(201); const list = async (query: string) => ( ( await request(server()) .get( `/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}&selectable=true${query}`, ) .set(auth('READONLY')) .expect(200) ).body as LotBody[] ).map((entry) => entry.id); expect(await list('')).toEqual([lot.id]); // The lot of an edited treatment stays selectable although it is empty. expect(await list(`&includeLotId=${emptyLot.id}`)).toEqual( expect.arrayContaining([lot.id, emptyLot.id]), ); await request(server()) .post(`/api/v1/veterinary-medicines/${medicine.id}/deactivate`) .set(auth('STAFF')) .expect(201); expect(await list('')).toEqual([]); expect(await list(`&includeLotId=${emptyLot.id}`)).toEqual([emptyLot.id]); await request(server()) .get( `/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}&selectable=true&includeLotId=bad%20id`, ) .set(auth('READONLY')) .expect(400); }); it('an inactive medicine takes no new stock: no new lot, no move to it', async () => { const off = await createMedicine('NoStock'); const other = await createMedicine('NoStockOther'); const lot = await createLot(other.id, 5); await request(server()) .post(`/api/v1/veterinary-medicines/${off.id}/deactivate`) .set(auth('STAFF')) .expect(201); const created = await request(server()) .post('/api/v1/veterinary-medicine-lots') .set(auth('STAFF')) .send({ medicineId: off.id, purchasedOn: '2026-08-01', quantity: 1 }) .expect(400); expect((created.body as { message: string }).message).toBe( 'Das Mittel ist deaktiviert.', ); await request(server()) .patch(`/api/v1/veterinary-medicine-lots/${lot.id}`) .set(auth('STAFF')) .send({ medicineId: off.id }) .expect(400); }); it('moving a lot to a medicine with another unit is a 400', async () => { const ml = await createMedicine('UnitMl'); const grams = await createMedicine('UnitG', { unit: 'G' }); const lot = await createLot(ml.id, 5); const response = await request(server()) .patch(`/api/v1/veterinary-medicine-lots/${lot.id}`) .set(auth('STAFF')) .send({ medicineId: grams.id }) .expect(400); expect((response.body as { message: string }).message).toBe( 'Die Einheit des Mittels passt nicht zur Packung.', ); }); it('setActive without a change writes no audit row', async () => { const medicine = await createMedicine('NoChange'); const countAudit = () => prisma.auditLog.count({ where: { entityType: 'VeterinaryMedicine', entityId: medicine.id }, }); const before = await countAudit(); await request(server()) .post(`/api/v1/veterinary-medicines/${medicine.id}/reactivate`) .set(auth('STAFF')) .expect(201); expect(await countAudit()).toBe(before); }); it('rejects a whitespace-only name or disposal reason with 400', async () => { const medicine = await createMedicine('Blank'); const lot = await createLot(medicine.id, 5); await request(server()) .post('/api/v1/veterinary-medicines') .set(auth('STAFF')) .send({ name: ' ', unit: 'ML' }) .expect(400); await request(server()) .patch(`/api/v1/veterinary-medicines/${medicine.id}`) .set(auth('STAFF')) .send({ name: ' \t ' }) .expect(400); await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'DISPOSAL', quantity: 1, reason: ' ' }) .expect(400); }); it('suggestions list own product names that are not in the catalogue yet', async () => { const product = `Sugg-${suffix}`; const known = `SuggKnown-${suffix}`; for (const [name, day, ingredient] of [ [product, '2026-05-01', 'Alt'], [product.toUpperCase(), '2026-06-01', 'Neu'], [known.toLowerCase(), '2026-06-02', 'Egal'], ]) { await prisma.treatment.create({ data: { colonyId, userId, date: new Date(`${day}T00:00:00Z`), product: name, activeIngredient: ingredient, withdrawalPeriodDays: 0, method: 'Träufeln', amount: '5 ml', }, }); } await createMedicine('SuggKnown'); const response = await request(server()) .get('/api/v1/veterinary-medicines/suggestions') .set(auth('READONLY')) .expect(200); const suggestions = response.body as SuggestionBody[]; expect( suggestions.find( (entry) => entry.name.toLowerCase() === known.toLowerCase(), ), ).toBeUndefined(); const mine = suggestions.find( (entry) => entry.name.toLowerCase() === product.toLowerCase(), ); expect(mine).toMatchObject({ treatmentCount: 2, latest: { activeIngredient: 'Neu', withdrawalPeriodDays: 0, method: 'Träufeln', amount: '5 ml', }, }); // nothing was created by looking at them expect( await prisma.veterinaryMedicine.count({ where: { name: { equals: product, mode: 'insensitive' } }, }), ).toBe(0); }); it('STAFF may create a lot, READONLY gets 403 on every write', async () => { const medicine = await createMedicine('Perm'); const lot = await createLot(medicine.id, 3); for (const [method, path, body] of [ ['post', '/api/v1/veterinary-medicines', { name: 'x', unit: 'ML' }], ['patch', `/api/v1/veterinary-medicines/${medicine.id}`, { notes: 'x' }], ['post', `/api/v1/veterinary-medicines/${medicine.id}/deactivate`, {}], ['post', `/api/v1/veterinary-medicines/${medicine.id}/reactivate`, {}], [ 'post', '/api/v1/veterinary-medicine-lots', { medicineId: medicine.id, purchasedOn: '2026-08-01', quantity: 1 }, ], ['patch', `/api/v1/veterinary-medicine-lots/${lot.id}`, { notes: 'x' }], [ 'post', `/api/v1/veterinary-medicine-lots/${lot.id}/movements`, { type: 'CORRECTION', actualRemaining: 1 }, ], ] as const) { await request(server()) [method](path) .set(auth('READONLY')) .send(body) .expect(403); } await request(server()) .get('/api/v1/veterinary-medicines') .set(auth('READONLY')) .expect(200); }); it('API_CLIENT gets 403 on every route', async () => { const medicine = await createMedicine('Client'); const lot = await createLot(medicine.id, 3); const routes = [ ['get', '/api/v1/veterinary-medicines'], ['get', '/api/v1/veterinary-medicines/suggestions'], ['get', `/api/v1/veterinary-medicines/${medicine.id}`], ['post', '/api/v1/veterinary-medicines'], ['patch', `/api/v1/veterinary-medicines/${medicine.id}`], ['post', `/api/v1/veterinary-medicines/${medicine.id}/deactivate`], ['post', `/api/v1/veterinary-medicines/${medicine.id}/reactivate`], ['get', '/api/v1/veterinary-medicine-lots'], ['get', `/api/v1/veterinary-medicine-lots/${lot.id}`], ['post', '/api/v1/veterinary-medicine-lots'], ['patch', `/api/v1/veterinary-medicine-lots/${lot.id}`], ['post', `/api/v1/veterinary-medicine-lots/${lot.id}/movements`], ] as const; for (const [method, path] of routes) { await request(server()) [method](path) .set(auth('API_CLIENT')) .send({}) .expect(403); } }); it('deactivates and reactivates a medicine instead of deleting it', async () => { const medicine = await createMedicine('Active'); const off = await request(server()) .post(`/api/v1/veterinary-medicines/${medicine.id}/deactivate`) .set(auth('STAFF')) .expect(201); expect(off.body).toMatchObject({ id: medicine.id, active: false }); const inactive = await request(server()) .get('/api/v1/veterinary-medicines?active=false') .set(auth('READONLY')) .expect(200); expect((inactive.body as MedicineBody[]).map((m) => m.id)).toContain( medicine.id, ); const on = await request(server()) .post(`/api/v1/veterinary-medicines/${medicine.id}/reactivate`) .set(auth('STAFF')) .expect(201); expect(on.body).toMatchObject({ active: true }); }); it('a POST with a repeated Idempotency-Key creates one lot', async () => { const medicine = await createMedicine('Idem'); const key = `idem-${randomUUID()}`; const body = { medicineId: medicine.id, purchasedOn: '2026-08-01', quantity: 2, batchNumber: `IDEM-${suffix}`, }; const first = await request(server()) .post('/api/v1/veterinary-medicine-lots') .set(auth('STAFF')) .set('Idempotency-Key', key) .send(body) .expect(201); const second = await request(server()) .post('/api/v1/veterinary-medicine-lots') .set(auth('STAFF')) .set('Idempotency-Key', key) .send(body) .expect(201); expect((second.body as LotBody).id).toBe((first.body as LotBody).id); expect( await prisma.veterinaryMedicineLot.count({ where: { medicineId: medicine.id }, }), ).toBe(1); }); it('rejects a quantity of 0 or NaN with 400', async () => { const medicine = await createMedicine('Qty'); const lot = await createLot(medicine.id, 5); for (const quantity of [0, -1, 'NaN', null, Infinity]) { await request(server()) .post('/api/v1/veterinary-medicine-lots') .set(auth('STAFF')) .send({ medicineId: medicine.id, purchasedOn: '2026-08-01', quantity }) .expect(400); } await request(server()) .patch(`/api/v1/veterinary-medicine-lots/${lot.id}`) .set(auth('STAFF')) .send({ quantity: 0 }) .expect(400); await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'DISPOSAL', quantity: 0, reason: 'x' }) .expect(400); await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'CORRECTION', actualRemaining: 'NaN' }) .expect(400); await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'TREATMENT', quantity: 1 }) .expect(400); }); it('validates the medicine: temperatures, withdrawal days, name length', async () => { await request(server()) .post('/api/v1/veterinary-medicines') .set(auth('STAFF')) .send({ name: `Temp-${suffix}`, unit: 'ML', minTemperatureC: 30, maxTemperatureC: 10, }) .expect(400); await request(server()) .post('/api/v1/veterinary-medicines') .set(auth('STAFF')) .send({ name: `Days-${suffix}`, unit: 'ML', withdrawalPeriodDays: -1 }) .expect(400); await request(server()) .post('/api/v1/veterinary-medicines') .set(auth('STAFF')) .send({ name: 'x'.repeat(201), unit: 'ML' }) .expect(400); const zero = await createMedicine('Zero', { withdrawalPeriodDays: 0 }); expect(zero).toMatchObject({ lotCount: 0, totalRemaining: 0 }); }); it('answers 404 for an unknown medicine, lot or lot of an unknown medicine', async () => { await request(server()) .get('/api/v1/veterinary-medicines/does-not-exist') .set(auth('READONLY')) .expect(404); await request(server()) .get('/api/v1/veterinary-medicine-lots/does-not-exist') .set(auth('READONLY')) .expect(404); await request(server()) .post('/api/v1/veterinary-medicine-lots') .set(auth('STAFF')) .send({ medicineId: 'does-not-exist', purchasedOn: '2026-08-01', quantity: 1, }) .expect(404); }); it('a movement can neither be changed nor deleted (no such routes)', async () => { const medicine = await createMedicine('Immutable'); const lot = await createLot(medicine.id, 5); const detail = await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'CORRECTION', actualRemaining: 1 }) .expect(201); const movementId = (detail.body as LotBody).movements?.[0].id ?? ''; const base = `/api/v1/veterinary-medicine-lots/${lot.id}/movements`; await request(server()) .patch(`${base}/${movementId}`) .set(auth('STAFF')) .send({ quantity: 9 }) .expect(404); await request(server()) .delete(`${base}/${movementId}`) .set(auth('STAFF')) .expect(404); await request(server()) .delete(`/api/v1/veterinary-medicine-lots/${lot.id}`) .set(auth('STAFF')) .expect(404); }); it('writes an audit entry for a correction and keeps the book of the lot', async () => { const medicine = await createMedicine('Audit'); const lot = await createLot(medicine.id, 8); await request(server()) .post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`) .set(auth('STAFF')) .send({ type: 'CORRECTION', actualRemaining: 5 }) .expect(201); const entries = await prisma.auditLog.findMany({ where: { entityType: 'VeterinaryMedicineLot', entityId: lot.id }, orderBy: { timestamp: 'asc' }, }); expect(entries.map((entry) => entry.action)).toEqual(['CREATE', 'UPDATE']); expect(entries[1].afterData).toMatchObject({ remaining: 5 }); }); });