- 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]>
794 lines
26 KiB
TypeScript
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 });
|
|
});
|
|
});
|