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 TreatmentBody { id: string; colonyId: string; startDate: string | null; endDate: string | null; } interface WithdrawalStatusBody { colonyId: string; activeUntil: string; lastBlockedDay: string; withdrawalText: string; product: string; } interface BulkTreatmentBody { bulkId: string; records: (TreatmentBody & { apiaryId: string; batchNumber: string })[]; } describe('Treatments (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const beekeeperEmail = `treatments-e2e-beekeeper-${Date.now()}@example.com`; const staffEmail = `treatments-e2e-staff-${Date.now()}@example.com`; const password = 'SuperSecret123!'; let beekeeperId: string; let staffId: string; let beekeeperToken: string; let staffToken: string; let apiaryId: string; let colonyId: string; let treatmentId: string; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const beekeeper = await prisma.user.create({ data: { email: beekeeperEmail, passwordHash, name: 'Beekeeper', role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; const staff = await prisma.user.create({ data: { email: staffEmail, passwordHash, name: 'Staff', role: { connect: { key: 'STAFF' } }, }, }); staffId = staff.id; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; staffToken = (await login(app, staffEmail, password)).accessToken; const apiary = await prisma.apiary.create({ data: { name: `Stand ${Date.now()}` }, }); apiaryId = apiary.id; const colony = await prisma.colony.create({ data: { code: `V-${Date.now()}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyId = colony.id; }); afterAll(async () => { const treatmentIds = ( await prisma.treatment.findMany({ where: { colonyId }, select: { id: true }, }) ).map((treatment) => treatment.id); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds } }, }); await prisma.treatment.deleteMany({ where: { colonyId } }); await prisma.suggestion.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: [beekeeperId, staffId] } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: [beekeeperId, staffId] } }, }); await prisma.user.deleteMany({ where: { id: { in: [beekeeperId, staffId] } }, }); await app.close(); }); it('rejects create for a STAFF user', () => { return request(app.getHttpServer()) .post('/api/v1/treatments') .set('Authorization', `Bearer ${staffToken}`) .send({ colonyId, date: new Date().toISOString(), product: 'Ameisensäure', }) .expect(403); }); it('lets a BEEKEEPER user record a treatment', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/treatments') .set('Authorization', `Bearer ${beekeeperToken}`) .send({ colonyId, date: new Date().toISOString(), product: 'Ameisensäure', amount: '15 ml', }) .expect(201); treatmentId = (response.body as TreatmentBody).id; expect(treatmentId).toEqual(expect.any(String)); }); it('wrote the audit entry and TreatmentRecorded together with the treatment', async () => { expect( await prisma.auditLog.count({ where: { entityType: 'Treatment', entityId: treatmentId, action: 'CREATE', userId: beekeeperId, }, }), ).toBe(1); const events = await prisma.domainEvent.findMany({ where: { aggregateType: 'Treatment', aggregateId: treatmentId }, }); expect(events).toHaveLength(1); expect(events[0]).toEqual( expect.objectContaining({ type: 'TreatmentRecorded', actorUserId: beekeeperId, payload: { colonyId }, }), ); }); it('lists treatments for the colony', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/colonies/${colonyId}/treatments`) .set('Authorization', `Bearer ${staffToken}`) .expect(200); const treatments = response.body as TreatmentBody[]; expect(treatments.some((entry) => entry.id === treatmentId)).toBe(true); }); it('clears start and end date on null and audits the edit', async () => { const correctionWhere = { aggregateType: 'Treatment', aggregateId: treatmentId, type: 'TreatmentCorrected', } as const; const correctionsBefore = await prisma.domainEvent.count({ where: correctionWhere, }); await request(app.getHttpServer()) .patch(`/api/v1/treatments/${treatmentId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ startDate: '2026-07-01', endDate: '2026-07-14' }) .expect(200); const response = await request(app.getHttpServer()) .patch(`/api/v1/treatments/${treatmentId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ startDate: null, endDate: null }) .expect(200); const treatment = response.body as TreatmentBody; expect(treatment.startDate).toBeNull(); expect(treatment.endDate).toBeNull(); const auditEntry = await prisma.auditLog.findFirst({ where: { entityType: 'Treatment', entityId: treatmentId, action: 'UPDATE', userId: beekeeperId, }, orderBy: { timestamp: 'desc' }, }); expect(auditEntry?.afterData).toEqual( expect.objectContaining({ startDate: null, endDate: null }), ); // Two edits in this test, one event each. const corrections = await prisma.domainEvent.findMany({ where: correctionWhere, }); expect(corrections).toHaveLength(correctionsBefore + 2); expect( corrections.every( (event) => (event.payload as { colonyId: string }).colonyId === colonyId, ), ).toBe(true); }); it.each(['date', 'product'])('rejects null for the required %s', (field) => request(app.getHttpServer()) .patch(`/api/v1/treatments/${treatmentId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ [field]: null }) .expect(400), ); describe('withdrawal status for a harvest date', () => { async function withdrawalStatusAt(at?: string) { const response = await request(app.getHttpServer()) .get('/api/v1/treatments/withdrawal-status') .query( at === undefined ? { colonyIds: colonyId } : { colonyIds: colonyId, at }, ) .set('Authorization', `Bearer ${staffToken}`) .expect(200); return response.body as WithdrawalStatusBody[]; } beforeAll(async () => { // Ended 2026-07-01 with 14 days withdrawal: harvestable from 2026-07-15. await request(app.getHttpServer()) .post('/api/v1/treatments') .set('Authorization', `Bearer ${beekeeperToken}`) .send({ colonyId, date: '2026-06-20', endDate: '2026-07-01', product: 'Oxalsäure', withdrawalPeriodDays: 14, }) .expect(201); }); it('reports the window as active on a harvest date inside it', async () => { await expect(withdrawalStatusAt('2026-07-10')).resolves.toEqual([ { colonyId, activeUntil: '2026-07-15T00:00:00.000Z', lastBlockedDay: '2026-07-14', withdrawalText: 'bis einschließlich 14.07. – ab 15.07. wieder erntefähig', product: 'Oxalsäure', }, ]); }); it('accepts a full ISO timestamp', async () => { await expect( withdrawalStatusAt('2026-07-14T23:59:59+02:00'), ).resolves.toHaveLength(1); }); it('reports nothing once the window has ended by the harvest date', async () => { await expect(withdrawalStatusAt('2026-07-15')).resolves.toEqual([]); }); it('ignores a treatment that starts after the harvest date', async () => { await expect(withdrawalStatusAt('2026-06-01')).resolves.toEqual([]); }); it('evaluates against now without a date', async () => { await expect(withdrawalStatusAt()).resolves.toEqual([]); }); it('rejects asking for more than 200 colonies at once', async () => { const ids = Array.from( { length: 201 }, (_, index) => `cl${index.toString().padStart(23, '0')}`, ); const response = await request(app.getHttpServer()) .get('/api/v1/treatments/withdrawal-status') .query({ colonyIds: ids.join(',') }) .set('Authorization', `Bearer ${staffToken}`) .expect(400); expect(JSON.stringify(response.body)).toContain( 'colonyIds darf höchstens 200 Volk-IDs enthalten', ); }); it.each(["colony'a", 'colony;drop', 'c'.repeat(65)])( 'rejects the invalid colony id %s', (badId) => request(app.getHttpServer()) .get('/api/v1/treatments/withdrawal-status') .query({ colonyIds: `${colonyId},${badId}` }) .set('Authorization', `Bearer ${staffToken}`) .expect(400), ); it.each(['gestern', '2026-02-30', '20260715', '2026-07-15T10Z', '2026'])( 'rejects the invalid date %s', (at) => request(app.getHttpServer()) .get('/api/v1/treatments/withdrawal-status') .query({ colonyIds: colonyId, at }) .set('Authorization', `Bearer ${staffToken}`) .expect(400), ); }); describe('GET /treatments/last-by-product', () => { const productName = `Oxuvar-${Date.now()}`; const lastByProduct = (token: string, product?: string) => request(app.getHttpServer()) .get('/api/v1/treatments/last-by-product') .query(product === undefined ? {} : { product }) .set('Authorization', `Bearer ${token}`); beforeAll(async () => { await prisma.treatment.createMany({ data: [ { colonyId, userId: beekeeperId, date: new Date('2026-05-01T00:00:00.000Z'), product: productName, supplier: 'Alt GmbH', }, { colonyId, userId: beekeeperId, date: new Date('2026-06-01T00:00:00.000Z'), product: productName, activeIngredient: 'Oxalsäure', withdrawalPeriodDays: 0, supplier: 'Neu GmbH', purchaseProof: 'RE-2026-17', prescribingVet: 'Dr. Imker', }, { colonyId, userId: staffId, date: new Date('2026-07-01T00:00:00.000Z'), product: productName, supplier: 'Fremd GmbH', }, ], }); }); it("returns the caller's newest treatment with that product, case-insensitive", async () => { const response = await lastByProduct( beekeeperToken, ` ${productName.toLowerCase()} `, ).expect(200); const newest = await prisma.treatment.findFirstOrThrow({ where: { userId: beekeeperId, product: productName, supplier: 'Neu GmbH', }, select: { id: true }, }); expect(response.body).toEqual({ treatment: { sourceTreatmentId: newest.id, sourceDate: '2026-06-01T00:00:00.000Z', product: productName, activeIngredient: 'Oxalsäure', withdrawalPeriodDays: 0, supplier: 'Neu GmbH', purchaseProof: 'RE-2026-17', prescribingVet: 'Dr. Imker', }, }); }); it("never returns another user's treatment", async () => { const response = await lastByProduct(beekeeperToken, productName).expect( 200, ); expect(JSON.stringify(response.body)).not.toContain('Fremd GmbH'); }); it('returns { treatment: null } without a match', async () => { const response = await lastByProduct( beekeeperToken, `Unbekannt-${Date.now()}`, ).expect(200); expect(response.body).toEqual({ treatment: null }); }); it('rejects a request without product', () => lastByProduct(beekeeperToken).expect(400)); it('rejects an empty product', () => lastByProduct(beekeeperToken, ' ').expect(400)); it('rejects a role without treatments:create', () => lastByProduct(staffToken, productName).expect(403)); }); describe('POST /treatments/bulk', () => { const suffix = Date.now(); const shared = { date: '2026-08-15', product: 'Oxalsäure', batchNumber: 'CH-1', withdrawalPeriodDays: 0, }; let secondApiaryId: string; // Two colonies at the first apiary, one at the second. let bulkColonyIds: string[]; let endedColonyId: string; const postBulk = (token: string, body: object, idempotencyKey?: string) => { const call = request(app.getHttpServer()) .post('/api/v1/treatments/bulk') .set('Authorization', `Bearer ${token}`); return ( idempotencyKey === undefined ? call : call.set('Idempotency-Key', idempotencyKey) ).send(body); }; const storedTreatments = () => prisma.treatment.count({ where: { colonyId: { in: [...bulkColonyIds, endedColonyId] } }, }); beforeAll(async () => { const secondApiary = await prisma.apiary.create({ data: { name: `Stand B ${suffix}` }, }); secondApiaryId = secondApiary.id; const colonyAt = async ( code: string, atApiaryId: string, status: 'ACTIVE' | 'WEAK' | 'DISSOLVED' = 'ACTIVE', ) => ( await prisma.colony.create({ data: { code: `${code}-${suffix}`, apiaryId: atApiaryId, colonyType: 'PRODUCTION_COLONY', status, }, }) ).id; bulkColonyIds = [ await colonyAt('TB-1', apiaryId), await colonyAt('TB-2', apiaryId, 'WEAK'), await colonyAt('TB-3', secondApiaryId), ]; endedColonyId = await colonyAt('TB-X', apiaryId, 'DISSOLVED'); }); afterAll(async () => { const colonyIds = [...bulkColonyIds, endedColonyId]; const treatmentIds = ( await prisma.treatment.findMany({ where: { colonyId: { in: colonyIds } }, select: { id: true }, }) ).map((treatment) => treatment.id); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds }, }, }); await prisma.treatment.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.suggestion.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); await prisma.apiary.deleteMany({ where: { id: secondApiaryId } }); await prisma.idempotencyRecord.deleteMany({ where: { userId: beekeeperId }, }); }); it("bulk creates one treatment per colony with the shared fields and each colony's apiary", async () => { const response = await postBulk(beekeeperToken, { ...shared, colonyIds: bulkColonyIds, }).expect(201); const body = response.body as BulkTreatmentBody; expect(body.bulkId).toEqual(expect.any(String)); expect(body.records).toHaveLength(3); expect(body.records.map((record) => record.colonyId)).toEqual( bulkColonyIds, ); expect( body.records.every((record) => record.batchNumber === 'CH-1'), ).toBe(true); expect(body.records.map((record) => record.apiaryId)).toEqual([ apiaryId, apiaryId, secondApiaryId, ]); }); it('bulk writes one TreatmentRecorded event and one audit entry per record', async () => { const response = await postBulk(beekeeperToken, { ...shared, colonyIds: bulkColonyIds, }).expect(201); const { bulkId, records } = response.body as BulkTreatmentBody; const ofThisBulk = { type: 'TreatmentRecorded', payload: { path: ['bulkId'], equals: bulkId }, }; expect(await prisma.domainEvent.count({ where: ofThisBulk })).toBe(3); const events = await prisma.domainEvent.findMany({ where: ofThisBulk }); expect( events .map((event) => ({ aggregateId: event.aggregateId, actorUserId: event.actorUserId, payload: event.payload, })) .sort((a, b) => a.aggregateId.localeCompare(b.aggregateId)), ).toEqual( records .map((record) => ({ aggregateId: record.id, actorUserId: beekeeperId, payload: { colonyId: record.colonyId, bulkId }, })) .sort((a, b) => a.aggregateId.localeCompare(b.aggregateId)), ); expect( await prisma.auditLog.count({ where: { entityType: 'Treatment', action: 'CREATE', userId: beekeeperId, entityId: { in: records.map((record) => record.id) }, }, }), ).toBe(3); }); it('bulk rejects the whole batch when one colony has ended and names it', async () => { const before = await storedTreatments(); const response = await postBulk(beekeeperToken, { ...shared, colonyIds: [...bulkColonyIds, endedColonyId], }).expect(400); expect( (response.body as { invalidColonyIds: string[] }).invalidColonyIds, ).toEqual([endedColonyId]); expect(await storedTreatments()).toBe(before); }); it.each([ [ 'an unknown id', () => [bulkColonyIds[0], `unknown-${suffix}`], 'Einige Völker sind nicht (mehr) erfassbar', ], ['no id', () => [], 'colonyIds braucht mindestens ein Volk'], [ '101 ids', () => [ ...bulkColonyIds, ...Array.from({ length: 98 }, (_, index) => `cl-${suffix}-${index}`), ], 'colonyIds darf höchstens 100 Völker enthalten', ], [ 'a duplicate id', () => [bulkColonyIds[0], bulkColonyIds[0]], 'colonyIds enthält ein Volk doppelt', ], [ 'an empty id', () => [bulkColonyIds[0], ''], 'colonyIds enthält eine leere Volk-ID', ], ])( 'bulk rejects unknown ids, 0 or 101 ids and duplicates: %s', async (_label, colonyIds, message) => { const before = await storedTreatments(); const response = await postBulk(beekeeperToken, { ...shared, colonyIds: colonyIds(), }).expect(400); expect(JSON.stringify(response.body)).toContain(message); expect(await storedTreatments()).toBe(before); }, ); it('bulk names an unknown id like an ended colony', async () => { const unknownId = `unknown-${suffix}`; const response = await postBulk(beekeeperToken, { ...shared, colonyIds: [bulkColonyIds[0], unknownId], }).expect(400); expect( (response.body as { invalidColonyIds: string[] }).invalidColonyIds, ).toEqual([unknownId]); }); it('bulk replays with the same Idempotency-Key', async () => { const key = `treatments-bulk-${suffix}`; const body = { ...shared, colonyIds: bulkColonyIds }; const before = await storedTreatments(); const first = await postBulk(beekeeperToken, body, key).expect(201); const replay = await postBulk(beekeeperToken, body, key).expect(201); expect((replay.body as BulkTreatmentBody).bulkId).toBe( (first.body as BulkTreatmentBody).bulkId, ); expect(await storedTreatments()).toBe(before + bulkColonyIds.length); }); it('bulk rejects a role without treatments:create', () => postBulk(staffToken, { ...shared, colonyIds: bulkColonyIds }).expect( 403, )); }); });