feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s

- 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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 13:43:44 +02:00
1 parent 8a31c42578
commit c18860c75c
73 files changed
+7121 -309

No files matched your search

+38
View File
@@ -30,6 +30,15 @@ describe('Apiaries (e2e)', () => {
let noColonyToken: string;
let fieldApiaryId: string;
// The ApiaryChanged codes of one apiary, oldest first.
async function changesOf(id: string): Promise<string[]> {
const events = await prisma.domainEvent.findMany({
where: { type: 'ApiaryChanged', aggregateId: id },
orderBy: { occurredAt: 'asc' },
});
return events.map((event) => (event.payload as { change: string }).change);
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
@@ -85,6 +94,12 @@ describe('Apiaries (e2e)', () => {
});
afterAll(async () => {
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'Apiary',
aggregateId: { in: [apiaryId, fieldApiaryId].filter(Boolean) },
},
});
if (fieldApiaryId) {
await prisma.task.deleteMany({
where: { title: { startsWith: 'field-e2e-' } },
@@ -154,6 +169,7 @@ describe('Apiaries (e2e)', () => {
.send({ notes: 'aktualisiert' })
.expect(200);
expect((updated.body as ApiaryBody).notes).toBe('aktualisiert');
expect(await changesOf(apiaryId)).toEqual(['CREATED']);
const deactivated = await request(app.getHttpServer())
.post(`/api/v1/apiaries/${apiaryId}/deactivate`)
@@ -166,6 +182,11 @@ describe('Apiaries (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(201);
expect((reactivated.body as ApiaryBody).status).toBe('ACTIVE');
expect(await changesOf(apiaryId)).toEqual([
'CREATED',
'DEACTIVATED',
'REACTIVATED',
]);
await request(app.getHttpServer())
.post(`/api/v1/apiaries/${apiaryId}/reactivate`)
@@ -173,6 +194,23 @@ describe('Apiaries (e2e)', () => {
.expect(409);
});
it('updating only the name of an apiary leaves no ApiaryChanged', async () => {
const before = await changesOf(apiaryId);
await request(app.getHttpServer())
.patch(`/api/v1/apiaries/${apiaryId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ name: 'Heimstand umbenannt' })
.expect(200);
expect(await changesOf(apiaryId)).toEqual(before);
await request(app.getHttpServer())
.patch(`/api/v1/apiaries/${apiaryId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ address: 'Neuer Weg 3' })
.expect(200);
expect(await changesOf(apiaryId)).toEqual([...before, 'LOCATION_CHANGED']);
});
it('lets publicLocationLabel be set, changed and cleared with null', async () => {
const patch = (body: object) =>
request(app.getHttpServer())
+22 -1
View File
@@ -163,6 +163,15 @@ describe('Colonies (e2e)', () => {
colonyId = created.id;
});
it('a new colony leaves one ColonyCreated event', async () => {
const events = await prisma.domainEvent.findMany({
where: { type: 'ColonyCreated', aggregateId: colonyId },
});
expect(events).toHaveLength(1);
expect(events[0].aggregateType).toBe('Colony');
expect(events[0].payload).toEqual({ apiaryId: apiaryAId });
});
it('moves the colony to another apiary and records history', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/move`)
@@ -176,6 +185,15 @@ describe('Colonies (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect((history.body as ColonyHistoryBody).apiaryHistory).toHaveLength(1);
const moved = await prisma.domainEvent.findMany({
where: { type: 'ColonyMoved', aggregateId: colonyId },
});
expect(moved).toHaveLength(1);
expect(moved[0].payload).toEqual({
fromApiaryId: apiaryAId,
toApiaryId: apiaryBId,
});
});
it('splits the colony into a new nucleus colony', async () => {
@@ -289,7 +307,10 @@ describe('Colonies (e2e)', () => {
expect((response.body as ColonyBody).status).toBe('DISSOLVED');
const events = await prisma.domainEvent.findMany({
where: { aggregateId: colonyId },
where: {
aggregateId: colonyId,
type: { in: ['ColonyStatusChanged', 'ColonyEnded'] },
},
orderBy: { occurredAt: 'asc' },
});
expect(events.map((event) => event.type)).toEqual([
+395
View File
@@ -221,3 +221,398 @@ describe('Documents (e2e)', () => {
).toBe(before + 1);
});
});
describe('Documents of specialist records (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}`;
const password = 'SuperSecret123!';
const roleIds: string[] = [];
const userIds: string[] = [];
let apiaryId: string;
let medicineId: string;
let lotId: string;
let noticeId: string;
let restrictionId: string;
let docsOnlyToken: string;
let docsAndManageToken: string;
let staffToken: string;
let beekeeperToken: string;
let lotDocumentId: string;
let noticeDocumentId: string;
let restrictionDocumentId: string;
const api = () => request(app.getHttpServer());
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
async function createUser(
label: string,
options: { permissions?: string[]; roleKey?: string },
): Promise<string> {
let roleId: string;
if (options.roleKey) {
roleId = (
await prisma.role.findUniqueOrThrow({ where: { key: options.roleKey } })
).id;
} else {
const role = await prisma.role.create({
data: {
key: `DOC_SPECIAL_${label.toUpperCase()}_${suffix}`,
name: `Documents special ${label}`,
permissions: {
create: (options.permissions ?? []).map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
roleId = role.id;
}
const email = `doc-special-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Documents special ${label}`,
role: { connect: { id: roleId } },
},
});
userIds.push(user.id);
return (await login(app, email, password)).accessToken;
}
const upload = (
token: string,
entityType: string,
entityId: string,
fileName = 'beleg.png',
) =>
api()
.post('/api/v1/documents')
.set(auth(token))
.field('entityType', entityType)
.field('entityId', entityId)
.attach('file', PNG_BYTES, {
filename: fileName,
contentType: 'image/png',
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const crud = [
'documents:read',
'documents:upload',
'documents:update',
'documents:delete',
];
docsOnlyToken = await createUser('docsonly', { permissions: crud });
docsAndManageToken = await createUser('docsmanage', {
permissions: [...crud, 'compliance:read', 'compliance:manage'],
});
staffToken = await createUser('staff', { roleKey: 'STAFF' });
beekeeperToken = await createUser('beekeeper', { roleKey: 'BEEKEEPER' });
const apiary = await prisma.apiary.create({
data: { name: `Dok-Spezial-Stand ${suffix}` },
});
apiaryId = apiary.id;
const medicine = await prisma.veterinaryMedicine.create({
data: { name: `Dok-Mittel ${suffix}`, unit: 'ML' },
});
medicineId = medicine.id;
const lot = await prisma.veterinaryMedicineLot.create({
data: { medicineId, purchasedOn: new Date('2026-05-01'), quantity: 10 },
});
lotId = lot.id;
const notice = await prisma.authorityNotice.create({
data: {
kind: 'REGISTRATION',
referenceDate: new Date('2026-05-01'),
countedAt: new Date('2026-05-01'),
snapshot: {},
totalColonies: 0,
},
});
noticeId = notice.id;
const restriction = await prisma.apiaryRestriction.create({
data: {
apiaryId,
disease: 'Test',
startsOn: new Date('2026-05-01'),
},
});
restrictionId = restriction.id;
});
afterAll(async () => {
await prisma.document.deleteMany({
where: { entityId: { in: [lotId, noticeId, restrictionId] } },
});
await prisma.apiaryRestriction.deleteMany({ where: { id: restrictionId } });
await prisma.authorityNotice.deleteMany({ where: { id: noticeId } });
await prisma.veterinaryMedicineLot.deleteMany({ where: { id: lotId } });
await prisma.veterinaryMedicine.deleteMany({ where: { id: medicineId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await fs.rm('./test-uploads', { recursive: true, force: true });
await app.close();
});
it('STAFF uploads a purchase receipt to a lot and downloads it again', async () => {
const response = await upload(staffToken, 'MEDICINE_LOT', lotId).expect(
201,
);
lotDocumentId = (response.body as DocumentBody).id;
const list = await api()
.get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`)
.set(auth(staffToken))
.expect(200);
expect(
(list.body as DocumentBody[]).some((entry) => entry.id === lotDocumentId),
).toBe(true);
const download = await api()
.get(`/api/v1/documents/${lotDocumentId}/download`)
.set(auth(staffToken))
.expect(200);
expect(Buffer.compare(download.body as Buffer, PNG_BYTES)).toBe(0);
expect(download.headers['cache-control']).toBe('no-store');
expect(list.headers['cache-control']).toBe('no-store');
});
it('a user with documents:read but without veterinary-medicines:read gets 403 listing and downloading a MEDICINE_LOT document', async () => {
await api()
.get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`)
.set(auth(docsOnlyToken))
.expect(403);
await api()
.get(`/api/v1/documents/${lotDocumentId}/download`)
.set(auth(docsOnlyToken))
.expect(403);
});
it('a user with documents:read but without compliance:read gets 403 listing AUTHORITY_NOTICE and APIARY_RESTRICTION documents', async () => {
await api()
.get(`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`)
.set(auth(docsOnlyToken))
.expect(403);
await api()
.get(
`/api/v1/documents?entityType=APIARY_RESTRICTION&entityId=${restrictionId}`,
)
.set(auth(docsOnlyToken))
.expect(403);
});
it('a user with documents:upload but without compliance:manage gets 403 uploading to an AUTHORITY_NOTICE', async () => {
const before = await prisma.document.count({
where: { entityId: noticeId },
});
await upload(docsOnlyToken, 'AUTHORITY_NOTICE', noticeId).expect(403);
await upload(docsOnlyToken, 'APIARY_RESTRICTION', restrictionId).expect(
403,
);
// STAFF has no compliance:manage either
await upload(staffToken, 'AUTHORITY_NOTICE', noticeId).expect(403);
expect(await prisma.document.count({ where: { entityId: noticeId } })).toBe(
before,
);
});
it('a user with compliance:manage uploads, lists and downloads authority documents', async () => {
const notice = await upload(
docsAndManageToken,
'AUTHORITY_NOTICE',
noticeId,
).expect(201);
noticeDocumentId = (notice.body as DocumentBody).id;
const restriction = await upload(
docsAndManageToken,
'APIARY_RESTRICTION',
restrictionId,
).expect(201);
restrictionDocumentId = (restriction.body as DocumentBody).id;
await api()
.get(`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`)
.set(auth(docsAndManageToken))
.expect(200);
await api()
.get(`/api/v1/documents/${noticeDocumentId}/download`)
.set(auth(docsAndManageToken))
.expect(200);
// documents:read alone must not reach it
await api()
.get(`/api/v1/documents/${restrictionDocumentId}/download`)
.set(auth(docsOnlyToken))
.expect(403);
});
it('re-linking a document to an APIARY_RESTRICTION needs compliance:manage', async () => {
// a document on a harmless record, owned by the docs-only user
const apiaryDoc = await upload(docsOnlyToken, 'APIARY', apiaryId).expect(
201,
);
const id = (apiaryDoc.body as DocumentBody).id;
await api()
.patch(`/api/v1/documents/${id}`)
.set(auth(docsOnlyToken))
.send({ entityType: 'APIARY_RESTRICTION', entityId: restrictionId })
.expect(403);
expect(
(await prisma.document.findUniqueOrThrow({ where: { id } })).entityType,
).toBe('APIARY');
await api()
.patch(`/api/v1/documents/${id}`)
.set(auth(docsAndManageToken))
.send({ entityType: 'APIARY_RESTRICTION', entityId: restrictionId })
.expect(200);
// moving it back out needs the right of the OLD type too
await api()
.patch(`/api/v1/documents/${id}`)
.set(auth(docsOnlyToken))
.send({ entityType: 'APIARY', entityId: apiaryId })
.expect(403);
await api()
.delete(`/api/v1/documents/${id}`)
.set(auth(docsAndManageToken))
.expect(204);
});
it('renaming a restricted document without the right is denied', () => {
return api()
.patch(`/api/v1/documents/${lotDocumentId}`)
.set(auth(docsOnlyToken))
.send({ fileName: 'neu.png' })
.expect(403);
});
it('deleting a MEDICINE_LOT document needs veterinary-medicines:update', async () => {
await api()
.delete(`/api/v1/documents/${lotDocumentId}`)
.set(auth(docsOnlyToken))
.expect(403);
expect(await prisma.document.count({ where: { id: lotDocumentId } })).toBe(
1,
);
await api()
.delete(`/api/v1/documents/${lotDocumentId}`)
.set(auth(beekeeperToken))
.expect(204);
});
it('answers 403, not 404, for an unauthorised upload to a missing MEDICINE_LOT record', async () => {
await upload(docsOnlyToken, 'MEDICINE_LOT', 'nope').expect(403);
});
describe('read/write split over HTTP', () => {
async function userWith(label: string, permissions: string[]) {
return createUser(label, { permissions });
}
it('compliance:read without compliance:manage lists (200) but cannot upload (403)', async () => {
const token = await userWith('comp-read', [
'documents:read',
'documents:upload',
'compliance:read',
]);
await api()
.get(
`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`,
)
.set(auth(token))
.expect(200);
await upload(token, 'AUTHORITY_NOTICE', noticeId).expect(403);
await upload(token, 'APIARY_RESTRICTION', restrictionId).expect(403);
});
it('veterinary-medicines:read without :update lists and downloads (200) but cannot delete or patch (403)', async () => {
const token = await userWith('med-read', [
'documents:read',
'documents:update',
'documents:delete',
'veterinary-medicines:read',
]);
const created = await upload(
beekeeperToken,
'MEDICINE_LOT',
lotId,
).expect(201);
const id = (created.body as DocumentBody).id;
await api()
.get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`)
.set(auth(token))
.expect(200);
await api()
.get(`/api/v1/documents/${id}/download`)
.set(auth(token))
.expect(200);
await api()
.delete(`/api/v1/documents/${id}`)
.set(auth(token))
.expect(403);
await api()
.patch(`/api/v1/documents/${id}`)
.set(auth(token))
.send({ fileName: 'x.png' })
.expect(403);
expect(await prisma.document.count({ where: { id } })).toBe(1);
});
it('compliance:manage without compliance:read uploads (201) but cannot list (403)', async () => {
const token = await userWith('comp-manage', [
'documents:read',
'documents:upload',
'compliance:manage',
]);
await upload(token, 'AUTHORITY_NOTICE', noticeId).expect(201);
await api()
.get(
`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`,
)
.set(auth(token))
.expect(403);
});
});
it('answers 404 for a missing record of the new types when the user has the right', async () => {
await upload(staffToken, 'MEDICINE_LOT', 'does-not-exist').expect(404);
await upload(docsAndManageToken, 'AUTHORITY_NOTICE', 'nope').expect(404);
await upload(docsAndManageToken, 'APIARY_RESTRICTION', 'nope').expect(404);
});
it('uploads keep the MIME allowlist and signature check for the new types', async () => {
await api()
.post('/api/v1/documents')
.set(auth(staffToken))
.field('entityType', 'MEDICINE_LOT')
.field('entityId', lotId)
.attach('file', Buffer.from('MZ not allowed'), {
filename: 'x.exe',
contentType: 'application/octet-stream',
})
.expect(400);
await api()
.post('/api/v1/documents')
.set(auth(staffToken))
.field('entityType', 'MEDICINE_LOT')
.field('entityId', lotId)
.attach('file', Buffer.from('plain text, not a png'), {
filename: 'fake.png',
contentType: 'image/png',
})
.expect(400);
});
});
+15
View File
@@ -0,0 +1,15 @@
[
"batchNumber",
"honeyType",
"lotNumber",
"bottlingDate",
"bestBeforeDate",
"jarSize",
"productName",
"legalName",
"originCountry",
"foodBusinessName",
"foodBusinessAddress",
"bestBeforeLine",
"lotLine"
]
+22
View File
@@ -70,6 +70,15 @@ describe('Import (e2e)', () => {
});
afterAll(async () => {
const colonyIds = (
await prisma.colony.findMany({
where: { apiaryId },
select: { id: true },
})
).map((colony) => colony.id);
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { apiaryId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.apiary.deleteMany({
@@ -143,6 +152,13 @@ describe('Import (e2e)', () => {
const firstReport = firstResponse.body as ImportReportBody;
expect(firstReport.created).toBe(1);
const colony = await prisma.colony.findUniqueOrThrow({ where: { code } });
const events = await prisma.domainEvent.findMany({
where: { type: 'ColonyCreated', aggregateId: colony.id },
});
expect(events).toHaveLength(1);
expect(events[0].payload).toEqual({ apiaryId });
const secondResponse = await request(app.getHttpServer())
.post('/api/v1/import/colonies')
.set('Authorization', `Bearer ${adminToken}`)
@@ -154,6 +170,12 @@ describe('Import (e2e)', () => {
const secondReport = secondResponse.body as ImportReportBody;
expect(secondReport.created).toBe(0);
expect(secondReport.skipped).toBe(1);
// The skipped re-import leaves no second event.
expect(
await prisma.domainEvent.count({
where: { type: 'ColonyCreated', aggregateId: colony.id },
}),
).toBe(1);
});
it('reports an error when the referenced apiary is unknown', async () => {
+40
View File
@@ -135,6 +135,46 @@ describe('Products (e2e)', () => {
}
});
it('stores legalName and clears it with null', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/products')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ name: `Rechtsname ${Date.now()}`, legalName: ' Blütenhonig ' })
.expect(201);
const id = (created.body as ProductBody).id;
try {
expect((created.body as { legalName: string | null }).legalName).toBe(
'Blütenhonig',
);
const changed = await request(app.getHttpServer())
.patch(`/api/v1/products/${id}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ legalName: 'Waldhonig' })
.expect(200);
expect((changed.body as { legalName: string | null }).legalName).toBe(
'Waldhonig',
);
const cleared = await request(app.getHttpServer())
.patch(`/api/v1/products/${id}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ legalName: null })
.expect(200);
expect(
(cleared.body as { legalName: string | null }).legalName,
).toBeNull();
await request(app.getHttpServer())
.patch(`/api/v1/products/${id}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ legalName: 'x'.repeat(201) })
.expect(400);
} finally {
await prisma.product.deleteMany({ where: { id } });
}
});
it('rejects a shelf life outside 1 to 60 months', async () => {
for (const shelfLifeMonths of [0, 61, 1.5]) {
await request(app.getHttpServer())
+37 -1
View File
@@ -31,6 +31,8 @@ describe('Public batch origin page (e2e)', () => {
let bottlingBatchId: string;
const lotNumber = `WH-E2E-${Date.now()}`;
const apiaryName = `Stand Wald ${Date.now()}`;
const legalName = `Rechtsname-${Date.now()}-honig`;
const foodBusinessAddress = `Geheimweg ${Date.now()}, 86450 Altenmünster`;
beforeAll(async () => {
app = await createTestApp();
@@ -90,7 +92,7 @@ describe('Public batch origin page (e2e)', () => {
});
honeyBatchId = honeyBatch.id;
const product = await prisma.product.create({
data: { name: 'Sommerhonig 500g' },
data: { name: 'Sommerhonig 500g', legalName: legalName },
});
productId = product.id;
const bottlingBatch = await prisma.bottlingBatch.create({
@@ -139,6 +141,40 @@ describe('Public batch origin page (e2e)', () => {
expect(body.harvestDates).toHaveLength(1);
});
it('the public origin page exposes neither foodBusinessAddress nor legalName', async () => {
const before = await prisma.siteSettings.findUnique({
where: { id: 'singleton' },
});
await prisma.siteSettings.upsert({
where: { id: 'singleton' },
update: { foodBusinessName: 'Imkerei Geheim', foodBusinessAddress },
create: {
id: 'singleton',
foodBusinessName: 'Imkerei Geheim',
foodBusinessAddress,
},
});
try {
const response = await request(app.getHttpServer())
.get(`/api/v1/public/batches/${lotNumber}`)
.expect(200);
const text = JSON.stringify(response.body);
expect(text).not.toContain(foodBusinessAddress);
expect(text).not.toContain('Imkerei Geheim');
expect(text).not.toContain(legalName);
expect(response.body).not.toHaveProperty('legalName');
expect(response.body).not.toHaveProperty('foodBusinessAddress');
} finally {
await prisma.siteSettings.update({
where: { id: 'singleton' },
data: {
foodBusinessName: before?.foodBusinessName ?? null,
foodBusinessAddress: before?.foodBusinessAddress ?? null,
},
});
}
});
it('never leaks the apiary name, location, address or coordinates', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/public/batches/${lotNumber}`)
+21 -1
View File
@@ -103,7 +103,11 @@ describe('Shop sync (e2e)', () => {
},
});
const product = await prisma.product.create({
data: { name: `Sommertracht 500 g ${suffix}`, sku: `SKU-${suffix}` },
data: {
name: `Sommertracht 500 g ${suffix}`,
sku: `SKU-${suffix}`,
legalName: `Rechtsname-${suffix}`,
},
});
productId = product.id;
const bottling = await prisma.bottlingBatch.create({
@@ -163,6 +167,22 @@ describe('Shop sync (e2e)', () => {
expect(raw).not.toContain('"userId"');
});
it('carries no label master data in the product payload', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${syncToken}`)
.expect(200);
const body = response.body as ShopSyncFeedBody;
const product = body.products.find((p) => p.id === productId);
expect(product).toBeDefined();
expect(product).not.toHaveProperty('legalName');
const raw = JSON.stringify(response.body);
expect(raw).not.toContain('legalName');
expect(raw).not.toContain('foodBusiness');
expect(raw).not.toContain(`Rechtsname-${suffix}`);
});
it('grants the sync account nothing beyond the feed', async () => {
await request(app.getHttpServer())
.get('/api/v1/harvests')
@@ -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 });
});
});