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 FeedingBody { id: string; colonyId: string; } interface FeedingSummaryEntry { unit: string; totalAmount: number; } interface BulkFeedingBody { bulkId: string; records: (FeedingBody & { feedType: string; amount: number; unit: string; batchNumber: string | null; userId: string; })[]; } describe('Feedings (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const staffEmail = `feedings-e2e-staff-${Date.now()}@example.com`; const readonlyEmail = `feedings-e2e-readonly-${Date.now()}@example.com`; const password = 'SuperSecret123!'; let staffId: string; let readonlyId: string; let staffToken: string; let readonlyToken: string; let apiaryId: string; let colonyId: string; let feedingId: string; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const staff = await prisma.user.create({ data: { email: staffEmail, passwordHash, name: 'Staff', role: { connect: { key: 'STAFF' } }, }, }); staffId = staff.id; const readonly = await prisma.user.create({ data: { email: readonlyEmail, passwordHash, name: 'Readonly', role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; staffToken = (await login(app, staffEmail, password)).accessToken; readonlyToken = (await login(app, readonlyEmail, 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 feedingIds = ( await prisma.feeding.findMany({ where: { colonyId }, select: { id: true }, }) ).map((feeding) => feeding.id); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } }, }); await prisma.feeding.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: [staffId, readonlyId] } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: [staffId, readonlyId] } }, }); await prisma.user.deleteMany({ where: { id: { in: [staffId, readonlyId] } }, }); await app.close(); }); it('rejects create for a READONLY user', () => { return request(app.getHttpServer()) .post('/api/v1/feedings') .set('Authorization', `Bearer ${readonlyToken}`) .send({ colonyId, date: new Date().toISOString(), feedType: 'SUGAR_WATER', amount: 2, unit: 'l', }) .expect(403); }); it('lets a STAFF user record a feeding', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/feedings') .set('Authorization', `Bearer ${staffToken}`) .send({ colonyId, date: new Date().toISOString(), feedType: 'SUGAR_WATER', amount: 2.5, unit: 'l', }) .expect(201); feedingId = (response.body as FeedingBody).id; expect(feedingId).toEqual(expect.any(String)); }); it('wrote the audit entry and FeedingRecorded together with the feeding', async () => { expect( await prisma.auditLog.count({ where: { entityType: 'Feeding', entityId: feedingId, action: 'CREATE', userId: staffId, }, }), ).toBe(1); const events = await prisma.domainEvent.findMany({ where: { aggregateType: 'Feeding', aggregateId: feedingId }, }); expect(events).toHaveLength(1); expect(events[0]).toEqual( expect.objectContaining({ type: 'FeedingRecorded', actorUserId: staffId, payload: { colonyId }, }), ); }); it('records FeedingCorrected on an edit', async () => { await request(app.getHttpServer()) .patch(`/api/v1/feedings/${feedingId}`) .set('Authorization', `Bearer ${staffToken}`) .send({ notes: 'korrigiert' }) .expect(200); const corrections = await prisma.domainEvent.findMany({ where: { aggregateType: 'Feeding', aggregateId: feedingId, type: 'FeedingCorrected', }, }); expect(corrections).toHaveLength(1); expect(corrections[0].payload).toEqual({ colonyId }); }); it('lists feedings for the colony', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/colonies/${colonyId}/feedings`) .set('Authorization', `Bearer ${readonlyToken}`) .expect(200); const feedings = response.body as FeedingBody[]; expect(feedings.some((entry) => entry.id === feedingId)).toBe(true); }); it('summarizes total fed amount per unit', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/colonies/${colonyId}/feedings/summary`) .set('Authorization', `Bearer ${readonlyToken}`) .expect(200); const summary = response.body as FeedingSummaryEntry[]; const litersEntry = summary.find((entry) => entry.unit === 'l'); expect(litersEntry?.totalAmount).toBe(2.5); }); describe('POST /feedings/bulk', () => { const suffix = Date.now(); const shared = { date: '2026-09-01', feedType: 'FEED_SYRUP', product: 'Apiinvert', amount: 10, unit: 'kg', batchNumber: 'L-2026-09', }; let bulkColonyIds: string[]; let endedColonyId: string; const postBulk = (token: string, body: object) => request(app.getHttpServer()) .post('/api/v1/feedings/bulk') .set('Authorization', `Bearer ${token}`) .send(body); const storedFeedings = () => prisma.feeding.count({ where: { colonyId: { in: [...bulkColonyIds, endedColonyId] } }, }); beforeAll(async () => { const colonyAt = async ( code: string, status: 'ACTIVE' | 'SOLD' = 'ACTIVE', ) => ( await prisma.colony.create({ data: { code: `${code}-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', status, }, }) ).id; bulkColonyIds = [await colonyAt('FB-1'), await colonyAt('FB-2')]; endedColonyId = await colonyAt('FB-X', 'SOLD'); }); afterAll(async () => { const colonyIds = [...bulkColonyIds, endedColonyId]; const feedingIds = ( await prisma.feeding.findMany({ where: { colonyId: { in: colonyIds } }, select: { id: true }, }) ).map((feeding) => feeding.id); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } }, }); await prisma.feeding.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); }); it('bulk creates one feeding per colony with the shared fields', async () => { const response = await postBulk(staffToken, { ...shared, colonyIds: bulkColonyIds, }).expect(201); const { bulkId, records } = response.body as BulkFeedingBody; expect(records.map((record) => record.colonyId)).toEqual(bulkColonyIds); for (const record of records) { expect(record).toEqual( expect.objectContaining({ feedType: 'FEED_SYRUP', amount: 10, unit: 'kg', batchNumber: 'L-2026-09', userId: staffId, }), ); } expect( await prisma.domainEvent.count({ where: { type: 'FeedingRecorded', payload: { path: ['bulkId'], equals: bulkId }, }, }), ).toBe(2); expect( await prisma.auditLog.count({ where: { entityType: 'Feeding', action: 'CREATE', entityId: { in: records.map((record) => record.id) }, }, }), ).toBe(2); }); it('bulk rejects the whole batch when one colony has ended and names it', async () => { const before = await storedFeedings(); const response = await postBulk(staffToken, { ...shared, colonyIds: [...bulkColonyIds, endedColonyId], }).expect(400); expect( (response.body as { invalidColonyIds: string[] }).invalidColonyIds, ).toEqual([endedColonyId]); expect(await storedFeedings()).toBe(before); }); it('bulk rejects a duplicate colony', () => postBulk(staffToken, { ...shared, colonyIds: [bulkColonyIds[0], bulkColonyIds[0]], }).expect(400)); it('bulk rejects a role without feedings:create', () => postBulk(readonlyToken, { ...shared, colonyIds: bulkColonyIds }).expect( 403, )); }); });