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'; import { drainDomainEvents } from './utils/domain-events'; /** * Regression tests for data-integrity rules that cut across modules: * repeated requests, concurrent quantity bookings, colony/queen state * transitions and duplicate automation tasks. */ describe('Data integrity (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const email = `integrity-e2e-${suffix}@example.com`; const password = 'SuperSecret123!'; let token: string; let userId: string; let apiaryId: string; const server = () => app.getHttpServer(); const auth = () => ({ Authorization: `Bearer ${token}` }); async function createColony( code: string, status: 'ACTIVE' | 'QUEENLESS' | 'WEAK' = 'ACTIVE', ) { return prisma.colony.create({ data: { code: `${code}-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', status, }, }); } async function createHoneyBatch(amount: number) { const extraction = await prisma.extraction.create({ data: { date: new Date(), userId }, }); return prisma.honeyBatch.create({ data: { batchNumber: `HB-${suffix}-${Math.round(Math.random() * 1e9)}`, extractionId: extraction.id, amount, initialAmount: amount, }, }); } function bottle(honeyBatchId: string, jarCount: number) { return request(server()) .post('/api/v1/bottling-batches') .set(auth()) .send({ honeyBatchId, date: '2026-08-01T00:00:00.000Z', bestBeforeDate: '2028-08-01T00:00:00.000Z', jarSize: 500, jarCount, lotNumber: `L-${suffix}-${Math.round(Math.random() * 1e9)}`, }); } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const user = await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: 'Integrity', role: { connect: { key: 'ADMIN' } }, }, }); userId = user.id; token = (await login(app, email, password)).accessToken; apiaryId = ( await prisma.apiary.create({ data: { name: `Integrity ${suffix}` } }) ).id; }); afterAll(async () => { // The events this file's requests recorded (all by its own user) and // the follow-up events their handlers recorded under the same // correlation id - other e2e files share the database. if (userId) { const correlationIds = ( await prisma.domainEvent.findMany({ where: { actorUserId: userId }, select: { correlationId: true }, distinct: ['correlationId'], }) ).map((event) => event.correlationId); await prisma.domainEvent.deleteMany({ where: { correlationId: { in: correlationIds } }, }); } await app.close(); }); describe('Idempotency-Key', () => { it('stores a repeated inspection only once and replays the first response', async () => { const colony = await createColony('IDEM'); const key = `idem-${suffix}-inspection-0001`; const body = { colonyId: colony.id, date: '2026-07-01T00:00:00.000Z', queenSeen: false, eggsPresent: false, }; const first = await request(server()) .post('/api/v1/inspections') .set(auth()) .set('Idempotency-Key', key) .send(body) .expect(201); const second = await request(server()) .post('/api/v1/inspections') .set(auth()) .set('Idempotency-Key', key) .send(body) .expect(201); expect((second.body as { id: string }).id).toBe( (first.body as { id: string }).id, ); expect( await prisma.inspection.count({ where: { colonyId: colony.id } }), ).toBe(1); await drainDomainEvents(app, [(first.body as { id: string }).id]); // The automation fired once, not once per delivery attempt. expect( await prisma.suggestion.count({ where: { colonyId: colony.id, source: 'RULE' }, }), ).toBe(1); }); it('rejects a key reused for a different endpoint', async () => { const colony = await createColony('IDEM2'); const key = `idem-${suffix}-crossuse-0001`; await request(server()) .post('/api/v1/inspections') .set(auth()) .set('Idempotency-Key', key) .send({ colonyId: colony.id, date: '2026-07-01T00:00:00.000Z' }) .expect(201); await request(server()) .post('/api/v1/feedings') .set(auth()) .set('Idempotency-Key', key) .send({ colonyId: colony.id, date: '2026-07-01T00:00:00.000Z', feedType: 'SUGAR_WATER', amount: 2, unit: 'l', }) .expect(409); }); it('ignores the header on endpoints that did not opt in, storing nothing', async () => { const key = `idem-${suffix}-not-opted-in`; await request(server()) .post('/api/v1/apiaries') .set(auth()) .set('Idempotency-Key', key) .send({ name: `Opt-out ${suffix}` }) .expect(201); expect( await prisma.idempotencyRecord.count({ where: { userId, key } }), ).toBe(0); }); it('rejects a malformed key', () => { return request(server()) .post('/api/v1/inspections') .set(auth()) .set('Idempotency-Key', 'short') .send({}) .expect(400); }); it('releases the key when the request failed, so a corrected retry works', async () => { const colony = await createColony('IDEM3'); const key = `idem-${suffix}-retry-00001`; await request(server()) .post('/api/v1/inspections') .set(auth()) .set('Idempotency-Key', key) .send({ colonyId: colony.id }) .expect(400); await request(server()) .post('/api/v1/inspections') .set(auth()) .set('Idempotency-Key', key) .send({ colonyId: colony.id, date: '2026-07-01T00:00:00.000Z' }) .expect(201); }); }); describe('Bottling quantities', () => { it('never over-books a honey batch under concurrent bottlings', async () => { const honeyBatch = await createHoneyBatch(10); // Each takes 6 kg of 10 kg - only one of them may succeed. const results = await Promise.all([ bottle(honeyBatch.id, 12), bottle(honeyBatch.id, 12), ]); const statuses = results.map((r) => r.status).sort(); expect(statuses).toEqual([201, 400]); const after = await prisma.honeyBatch.findUniqueOrThrow({ where: { id: honeyBatch.id }, }); expect(after.amount).toBeCloseTo(4, 3); const bookings = await prisma.batchMovement.findMany({ where: { honeyBatchId: honeyBatch.id }, }); expect(bookings.map((booking) => booking.quantity)).toEqual([-6000]); }); it('gives the amount back when a bottling is removed', async () => { const honeyBatch = await createHoneyBatch(5); const created = await bottle(honeyBatch.id, 3).expect(201); await request(server()) .delete( `/api/v1/bottling-batches/${(created.body as { id: string }).id}`, ) .set(auth()) .expect(200); const after = await prisma.honeyBatch.findUniqueOrThrow({ where: { id: honeyBatch.id }, }); expect(after.amount).toBeCloseTo(5, 3); }); }); describe('Stock movements', () => { it('refuses an outgoing movement larger than the stock', async () => { const item = await prisma.stockItem.create({ data: { name: `Gläser ${suffix}`, category: 'CONSUMABLE', unit: 'Stk', quantity: 5, }, }); await request(server()) .post(`/api/v1/stock-items/${item.id}/movements`) .set(auth()) .send({ type: 'OUT', quantity: 6 }) .expect(400); const after = await prisma.stockItem.findUniqueOrThrow({ where: { id: item.id }, }); expect(after.quantity).toBe(5); }); it('proposes only one open reorder while the item stays below minimum', async () => { const item = await prisma.stockItem.create({ data: { name: `Deckel ${suffix}`, category: 'CONSUMABLE', unit: 'Stk', quantity: 10, minThreshold: 8, }, }); for (const quantity of [3, 1, 1]) { await request(server()) .post(`/api/v1/stock-items/${item.id}/movements`) .set(auth()) .send({ type: 'OUT', quantity }) .expect(201); } expect( await prisma.suggestion.count({ where: { relatedId: item.id, ruleKey: 'STOCK_ITEM_LOW', status: 'OPEN', }, }), ).toBe(1); }); }); describe('Colony transitions', () => { it('lets a queenless colony be united with another colony', async () => { const survivor = await createColony('SURV'); const queenless = await createColony('QL', 'QUEENLESS'); await request(server()) .post(`/api/v1/colonies/${survivor.id}/merge`) .set(auth()) .send({ mergedColonyIds: [queenless.id] }) .expect(201); const merged = await prisma.colony.findUniqueOrThrow({ where: { id: queenless.id }, }); expect(merged.status).toBe('MERGED'); }); it('lets a weak colony be moved to another apiary', async () => { const weak = await createColony('WEAK', 'WEAK'); const otherApiary = await prisma.apiary.create({ data: { name: `Other ${suffix}` }, }); await request(server()) .post(`/api/v1/colonies/${weak.id}/move`) .set(auth()) .send({ apiaryId: otherApiary.id }) .expect(201); }); it('refuses to merge a colony into itself', async () => { const colony = await createColony('SELF'); await request(server()) .post(`/api/v1/colonies/${colony.id}/merge`) .set(auth()) .send({ mergedColonyIds: [colony.id] }) .expect(400); const after = await prisma.colony.findUniqueOrThrow({ where: { id: colony.id }, }); expect(after.status).toBe('ACTIVE'); }); it('refuses to dissolve a colony that is already dissolved', async () => { const colony = await createColony('DISS'); await request(server()) .post(`/api/v1/colonies/${colony.id}/dissolve`) .set(auth()) .expect(201); await request(server()) .post(`/api/v1/colonies/${colony.id}/dissolve`) .set(auth()) .expect(409); }); }); describe('Queen assignment', () => { it('moves a queen between colonies and leaves the old colony queenless', async () => { const colonyA = await createColony('QA'); const colonyB = await createColony('QB', 'QUEENLESS'); const queen = await prisma.queen.create({ data: { code: `Q-${suffix}` }, }); await request(server()) .post(`/api/v1/queens/${queen.id}/assign-colony`) .set(auth()) .send({ colonyId: colonyA.id }) .expect(201); await request(server()) .post(`/api/v1/queens/${queen.id}/assign-colony`) .set(auth()) .send({ colonyId: colonyB.id }) .expect(201); const a = await prisma.colony.findUniqueOrThrow({ where: { id: colonyA.id }, }); const b = await prisma.colony.findUniqueOrThrow({ where: { id: colonyB.id }, }); expect(a.currentQueenId).toBeNull(); expect(a.status).toBe('QUEENLESS'); expect(b.currentQueenId).toBe(queen.id); expect(b.status).toBe('ACTIVE'); const openHistory = await prisma.queenColonyHistory.findMany({ where: { queenId: queen.id, endDate: null }, }); expect(openHistory.map((h) => h.colonyId)).toEqual([colonyB.id]); }); it('refuses to assign a queen to a dissolved colony', async () => { const colony = await createColony('QD'); await prisma.colony.update({ where: { id: colony.id }, data: { status: 'DISSOLVED' }, }); const queen = await prisma.queen.create({ data: { code: `QD-${suffix}` }, }); await request(server()) .post(`/api/v1/queens/${queen.id}/assign-colony`) .set(auth()) .send({ colonyId: colony.id }) .expect(409); }); }); describe('Tasks', () => { it('keeps the first completion when a task is completed twice', async () => { const created = await request(server()) .post('/api/v1/tasks') .set(auth()) .send({ title: `Doppelklick ${suffix}` }) .expect(201); const id = (created.body as { id: string }).id; const first = await request(server()) .post(`/api/v1/tasks/${id}/complete`) .set(auth()) .expect(201); const second = await request(server()) .post(`/api/v1/tasks/${id}/complete`) .set(auth()) .expect(201); expect((second.body as { completedAt: string }).completedAt).toBe( (first.body as { completedAt: string }).completedAt, ); // Announced once: a second TaskCompleted would send the webhook twice. await expect( prisma.domainEvent.count({ where: { aggregateId: id, type: 'TaskCompleted' }, }), ).resolves.toBe(1); }); it('announces a completion once when two requests complete a task at the same time', async () => { const created = await request(server()) .post('/api/v1/tasks') .set(auth()) .send({ title: `Gleichzeitig erledigt ${suffix}` }) .expect(201); const id = (created.body as { id: string }).id; const [first, second] = await Promise.all([ request(server()) .post(`/api/v1/tasks/${id}/complete`) .set(auth()) .expect(201), request(server()) .post(`/api/v1/tasks/${id}/complete`) .set(auth()) .expect(201), ]); expect((second.body as { completedAt: string }).completedAt).toBe( (first.body as { completedAt: string }).completedAt, ); await expect( prisma.domainEvent.count({ where: { aggregateId: id, type: 'TaskCompleted' }, }), ).resolves.toBe(1); await expect( prisma.auditLog.count({ where: { entityType: 'Task', entityId: id, action: 'UPDATE' }, }), ).resolves.toBe(1); }); it('announces a completion once when an edit into DONE and "Erledigt" arrive at the same time', async () => { const created = await request(server()) .post('/api/v1/tasks') .set(auth()) .send({ title: `Bearbeitet und erledigt ${suffix}` }) .expect(201); const id = (created.body as { id: string }).id; const [edited, completed] = await Promise.all([ request(server()) .patch(`/api/v1/tasks/${id}`) .set(auth()) .send({ status: 'DONE' }) .expect(200), request(server()) .post(`/api/v1/tasks/${id}/complete`) .set(auth()) .expect(201), ]); expect((edited.body as { completedAt: string }).completedAt).toBe( (completed.body as { completedAt: string }).completedAt, ); await expect( prisma.domainEvent.count({ where: { aggregateId: id, type: 'TaskCompleted' }, }), ).resolves.toBe(1); }); it('clears completedAt when a done task is reopened', async () => { const created = await request(server()) .post('/api/v1/tasks') .set(auth()) .send({ title: `Wieder offen ${suffix}` }) .expect(201); const id = (created.body as { id: string }).id; await request(server()) .post(`/api/v1/tasks/${id}/complete`) .set(auth()) .expect(201); const reopened = await request(server()) .patch(`/api/v1/tasks/${id}`) .set(auth()) .send({ status: 'OPEN' }) .expect(200); expect( (reopened.body as { completedAt: string | null }).completedAt, ).toBeNull(); }); it('gives a task generated from a recurring template its due date', async () => { const dueDate = new Date('2026-01-15T00:00:00.000Z'); const template = await prisma.taskTemplate.create({ data: { title: `Wiederkehrend ${suffix}`, recurrenceType: 'INTERVAL_DAYS', intervalDays: 7, nextDueDate: dueDate, }, }); await request(server()).get('/api/v1/tasks').set(auth()).expect(200); const generated = await prisma.task.findMany({ where: { sourceTemplateId: template.id }, }); // Months of missed weekly occurrences collapse into one reminder // instead of a pile of stale duplicates. expect(generated).toHaveLength(1); expect(generated[0].dueDate).not.toBeNull(); const refreshed = await prisma.taskTemplate.findUniqueOrThrow({ where: { id: template.id }, }); expect(refreshed.nextDueDate.getTime()).toBeGreaterThan(Date.now()); // One TaskCreated carrying that due date, so the calendar entry and // the webhook follow the generated task like any other. const events = await prisma.domainEvent.findMany({ where: { aggregateType: 'Task', aggregateId: generated[0].id }, }); expect(events).toEqual([ expect.objectContaining({ type: 'TaskCreated', actorUserId: null, payload: expect.objectContaining({ dueDate: generated[0].dueDate?.toISOString().slice(0, 10), }) as unknown, }), ]); }); }); describe('Input plausibility', () => { it('rejects an inspection rating outside the 1–5 scale', async () => { const colony = await createColony('SCALE'); await request(server()) .post('/api/v1/inspections') .set(auth()) .send({ colonyId: colony.id, date: '2026-07-01T00:00:00.000Z', gentleness: 9, }) .expect(400); }); it('rejects a harvest whose net weight exceeds its gross weight', async () => { const colony = await createColony('HW'); await request(server()) .post('/api/v1/harvests') .set(auth()) .send({ apiaryId, colonyIds: [colony.id], date: '2026-07-01T00:00:00.000Z', grossWeight: 20, netWeight: 25, }) .expect(400); }); it('rejects a harvest listing the same colony twice', async () => { const colony = await createColony('HD'); await request(server()) .post('/api/v1/harvests') .set(auth()) .send({ apiaryId, colonyIds: [colony.id, colony.id], date: '2026-07-01T00:00:00.000Z', }) .expect(400); }); }); });