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
@@ -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())
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
"batchNumber",
|
||||
"honeyType",
|
||||
"lotNumber",
|
||||
"bottlingDate",
|
||||
"bestBeforeDate",
|
||||
"jarSize",
|
||||
"productName",
|
||||
"legalName",
|
||||
"originCountry",
|
||||
"foodBusinessName",
|
||||
"foodBusinessAddress",
|
||||
"bestBeforeLine",
|
||||
"lotLine"
|
||||
]
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user