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; product: 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 () => { await prisma.treatment.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('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 () => { 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 }), ); }); 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', product: 'Oxalsäure', }, ]); }); it('accepts a full ISO timestamp', async () => { await expect( withdrawalStatusAt('2026-07-14T23:59:59.000Z'), ).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)); }); });