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]>
This commit is contained in:
1 parent
8a31c42578
commit
c18860c75c
73 files changed
+7121
-309
No files matched your search
@@ -0,0 +1,793 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user