Files
ApiCore/ApiCore-Backend/test/veterinary-medicines.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 c18860c75c
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s
feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- Veterinary medicine catalogue, lots and append-only movement ledger
  (rest always computed; corrections as counter-bookings; inactive
  medicines take no new stock; unit must match on lot moves).
- Documents of medicine lots, authority notices and restrictions need the
  domain right on top of documents:* (403 before any lookup).
- Label particulars: pure checkLabel with six mandatory gaps and four
  warnings, AFM font metrics, Berlin-day dates, geometric reading order
  for split addresses, new master-data fields with row-locked updates.
- Herd events ColonyCreated, ColonyMoved, ApiaryChanged and
  TskReferenceDayReached (ids only, never forwarded to webhooks); CSV
  import emits ColonyCreated with audit.
- ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70.

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

794 lines
26 KiB
TypeScript

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<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}`;
const password = 'SuperSecret123!';
const tokens: Record<string, string> = {};
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<string, unknown> = {},
): Promise<MedicineBody> {
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<string, unknown> = {},
): Promise<LotBody> {
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<LotBody> {
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 });
});
});