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 { NotificationsService } from '../src/notifications/notifications.service'; import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service'; import { AutomationService } from '../src/automation/automation.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; /** * Side effects that follow a committed write (automation rules, calendar * sync, webhooks) must never turn that write into an error response: the * offline queue would send the request again and store the record twice - * for treatments that is a duplicate entry in the Bestandsbuch. */ describe('Post-commit side effects (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const email = `post-commit-e2e-${suffix}@example.com`; const password = 'SuperSecret123!'; let token: string; let apiaryId: string; const server = () => app.getHttpServer(); const auth = () => ({ Authorization: `Bearer ${token}` }); async function createColony(code: string) { return prisma.colony.create({ data: { code: `${code}-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: 'Post Commit', role: { connect: { key: 'ADMIN' } }, }, }); token = (await login(app, email, password)).accessToken; apiaryId = ( await prisma.apiary.create({ data: { name: `Post Commit ${suffix}` } }) ).id; }); afterEach(() => { jest.restoreAllMocks(); }); afterAll(async () => { await app.close(); }); describe('when a side effect fails', () => { beforeEach(() => { // Automation: the task is created, then the warning fails. jest .spyOn(app.get(NotificationsService), 'record') .mockRejectedValue(new Error('notifications down')); // Calendar sync: enqueue cannot read its settings. jest .spyOn(app.get(CalendarSyncSettingsService), 'get') .mockRejectedValue(new Error('calendar settings down')); // Webhooks: the subscriptions cannot be loaded. jest .spyOn(prisma.webhookSubscription, 'findMany') .mockRejectedValue(new Error('webhook subscriptions down')); }); it('still answers 201 for an inspection and replays it on a retry', async () => { const colony = await createColony('PC-INSP'); const key = `post-commit-${suffix}-inspection`; 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 retry = await request(server()) .post('/api/v1/inspections') .set(auth()) .set('Idempotency-Key', key) .send(body) .expect(201); expect((retry.body as { id: string }).id).toBe( (first.body as { id: string }).id, ); expect( await prisma.inspection.count({ where: { colonyId: colony.id } }), ).toBe(1); // The rule still did its main job before the warning failed. expect( await prisma.task.count({ where: { relatedId: colony.id, sourceAutomationTrigger: 'QUEENLESS_NO_EGGS', }, }), ).toBe(1); }); it('still answers 201 for a treatment when the rule evaluation fails', async () => { jest .spyOn(prisma.automationRule, 'upsert') .mockRejectedValue(new Error('automation rules down')); const colony = await createColony('PC-TREAT'); const key = `post-commit-${suffix}-treatment`; const body = { colonyId: colony.id, date: '2026-08-01T00:00:00.000Z', product: 'Oxalsäure', }; const first = await request(server()) .post('/api/v1/treatments') .set(auth()) .set('Idempotency-Key', key) .send(body) .expect(201); const retry = await request(server()) .post('/api/v1/treatments') .set(auth()) .set('Idempotency-Key', key) .send(body) .expect(201); expect((retry.body as { id: string }).id).toBe( (first.body as { id: string }).id, ); expect( await prisma.treatment.count({ where: { colonyId: colony.id } }), ).toBe(1); }); }); describe('automation tasks', () => { it('leaves one open task when several triggers for the same colony arrive at once', async () => { const automation = app.get(AutomationService); // The same object crossing the threshold on several pods/requests at // once: every evaluation sees "no open task yet" unless checking and // creating are serialised. const item = await prisma.stockItem.create({ data: { name: `Race ${suffix}`, category: 'CONSUMABLE', unit: 'Stück', quantity: 1, minThreshold: 5, }, }); await Promise.all( Array.from({ length: 8 }, () => automation.evaluateStockItem(item)), ); expect( await prisma.task.count({ where: { relatedId: item.id, sourceAutomationTrigger: 'STOCK_ITEM_LOW', status: { in: ['OPEN', 'IN_PROGRESS'] }, }, }), ).toBe(1); }); it('leaves one open task when several measurements for the same colony arrive at once', async () => { const colony = await createColony('PC-RACE-HTTP'); const measurement = { colonyId: colony.id, date: '2026-08-15T00:00:00.000Z', method: 'NATURAL_MITE_FALL', miteCount: 20, // One day on the board: 20 mites per day, above the default 10. durationMinutes: 24 * 60, }; await Promise.all( Array.from({ length: 6 }, () => request(server()) .post('/api/v1/varroa-measurements') .set(auth()) .send(measurement) .expect(201), ), ); expect( await prisma.task.count({ where: { relatedId: colony.id, sourceAutomationTrigger: 'VARROA_THRESHOLD', status: { in: ['OPEN', 'IN_PROGRESS'] }, }, }), ).toBe(1); }); }); });