import { Logger, NotFoundException } from '@nestjs/common'; import { AutomationService } from './automation.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { NotificationsService } from '../notifications/notifications.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { AutomationTriggerType, Prisma } from '../../generated/prisma'; describe('AutomationService', () => { let service: AutomationService; let prisma: { automationRule: { upsert: jest.Mock; findMany: jest.Mock; findUnique: jest.Mock; update: jest.Mock; }; automationSequence: { upsert: jest.Mock }; task: { create: jest.Mock; findFirst: jest.Mock }; $transaction: jest.Mock; $queryRaw: jest.Mock; }; let auditLog: { record: jest.Mock }; let notifications: { record: jest.Mock }; let webhooks: { dispatch: jest.Mock }; const queenlessRule = { id: 'rule-1', triggerType: 'QUEENLESS_NO_EGGS', active: true, config: {}, actionTemplate: 'Kontrolle empfohlen', }; const feedRule = { id: 'rule-2', triggerType: 'FEED_STATUS_CRITICAL', active: true, config: {}, actionTemplate: 'Fütterung erforderlich', }; const varroaRule = { id: 'rule-3', triggerType: 'VARROA_THRESHOLD', active: true, config: { thresholdMitesPerDay: 10 }, actionTemplate: 'Behandlung prüfen', }; beforeEach(() => { prisma = { automationRule: { upsert: jest.fn().mockResolvedValue(undefined), findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), }, automationSequence: { upsert: jest.fn() }, task: { create: jest.fn().mockResolvedValue({ id: 'task-1' }), findFirst: jest.fn().mockResolvedValue(null), }, // Interactive transactions run against the same mocks. $transaction: jest.fn((work: (tx: unknown) => unknown) => work(prisma)), $queryRaw: jest.fn().mockResolvedValue([]), }; auditLog = { record: jest.fn() }; notifications = { record: jest.fn() }; webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; service = new AutomationService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, notifications as unknown as NotificationsService, webhooks as unknown as WebhooksService, ); }); it('findById throws NotFoundException when missing', async () => { prisma.automationRule.findUnique.mockResolvedValue(null); await expect(service.findById('missing')).rejects.toBeInstanceOf( NotFoundException, ); }); it('findAll seeds all default rules before returning them', async () => { prisma.automationRule.findMany.mockResolvedValue([ queenlessRule, varroaRule, feedRule, ]); const result = await service.findAll(); expect(prisma.automationRule.upsert).toHaveBeenCalledTimes( Object.values(AutomationTriggerType).length, ); expect(result).toHaveLength(3); }); // Rule evaluation runs after the triggering record is committed. A failure // here must not turn that request into an error - the client would retry // and store the inspection or treatment a second time. describe('post-commit evaluation', () => { let errorSpy: jest.SpyInstance; beforeEach(() => { errorSpy = jest .spyOn(Logger.prototype, 'error') .mockImplementation(() => undefined); prisma.automationRule.upsert.mockRejectedValue(new Error('db down')); }); afterEach(() => { errorSpy.mockRestore(); }); const colony = { id: 'colony-1', healthStatus: 'GOOD' }; const evaluations: [string, string, (s: AutomationService) => unknown][] = [ [ 'evaluateInspection', 'Inspection inspection-1', (s) => s.evaluateInspection({ id: 'inspection-1', colonyId: 'colony-1', } as never), ], [ 'evaluateVarroaMeasurement', 'VarroaMeasurement measurement-1', (s) => s.evaluateVarroaMeasurement({ id: 'measurement-1', colonyId: 'colony-1', } as never), ], [ 'evaluateSwarmControlCheck', 'SwarmControlCheck check-1', (s) => s.evaluateSwarmControlCheck({ id: 'check-1', colonyId: 'colony-1', } as never), ], [ 'evaluateColonyHealth', 'Colony colony-1', (s) => s.evaluateColonyHealth( colony as never, { ...colony, healthStatus: 'CRITICAL' } as never, ), ], [ 'evaluateStockItem', 'StockItem item-1', (s) => s.evaluateStockItem({ id: 'item-1', quantity: 1, minThreshold: 5, } as never), ], [ 'evaluateRawMaterialLow', 'RawMaterialKind BEESWAX', (s) => s.evaluateRawMaterialLow('BEESWAX', 100), ], [ 'evaluateTreatment', 'Treatment treatment-1', (s) => s.evaluateTreatment({ id: 'treatment-1', colonyId: 'colony-1', date: new Date('2026-08-01'), endDate: null, product: 'Ameisensäure', } as never), ], [ 'evaluateFeeding', 'Feeding feeding-1', (s) => s.evaluateFeeding({ id: 'feeding-1', colonyId: 'colony-1', date: new Date('2026-09-01'), amount: 5, unit: 'l', } as never), ], ]; it.each(evaluations)( '%s never throws and logs the failure with the record', async (_name, subject, evaluate) => { await expect(evaluate(service)).resolves.toBeUndefined(); expect(errorSpy).toHaveBeenCalledTimes(1); const [message] = errorSpy.mock.calls[0] as [string]; expect(message).toContain(subject); expect(message).toContain('db down'); }, ); it('still throws from generateNumber, which runs before the write', async () => { await expect( service.generateNumber('HONEY_BATCH_NUMBER_FORMAT'), ).rejects.toThrow('db down'); expect(errorSpy).not.toHaveBeenCalled(); }); }); describe('evaluateInspection', () => { it('creates a task and notification when queen not seen and no eggs present', async () => { prisma.automationRule.findMany.mockResolvedValue([queenlessRule]); await service.evaluateInspection({ id: 'inspection-1', colonyId: 'colony-1', queenSeen: false, eggsPresent: false, feedStatus: null, } as never); expect(prisma.task.create).toHaveBeenCalledTimes(1); const [[taskArg]] = prisma.task.create.mock.calls as [ [{ data: { relatedId: string; sourceInspectionId?: string } }], ]; expect(taskArg.data.relatedId).toBe('colony-1'); expect(taskArg.data.sourceInspectionId).toBe('inspection-1'); expect(notifications.record).toHaveBeenCalledTimes(1); }); it('does not trigger when the queen was seen', async () => { prisma.automationRule.findMany.mockResolvedValue([queenlessRule]); await service.evaluateInspection({ id: 'inspection-2', colonyId: 'colony-1', queenSeen: true, eggsPresent: false, feedStatus: null, } as never); expect(prisma.task.create).not.toHaveBeenCalled(); }); it('triggers when feed status is kritisch, case-insensitive', async () => { prisma.automationRule.findMany.mockResolvedValue([feedRule]); await service.evaluateInspection({ id: 'inspection-3', colonyId: 'colony-1', queenSeen: true, eggsPresent: true, feedStatus: 'Kritisch', } as never); expect(prisma.task.create).toHaveBeenCalledTimes(1); }); it('does not trigger a deactivated rule', async () => { prisma.automationRule.findMany.mockResolvedValue([]); await service.evaluateInspection({ id: 'inspection-4', colonyId: 'colony-1', queenSeen: false, eggsPresent: false, feedStatus: 'kritisch', } as never); expect(prisma.task.create).not.toHaveBeenCalled(); }); }); describe('evaluateVarroaMeasurement', () => { it('creates a task when the calculated value meets the threshold', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); await service.evaluateVarroaMeasurement({ id: 'measurement-1', colonyId: 'colony-1', method: 'NATURAL_MITE_FALL', calculatedValue: 12, miteCount: null, } as never); expect(prisma.task.create).toHaveBeenCalledTimes(1); }); it('does not trigger when below the threshold', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); await service.evaluateVarroaMeasurement({ id: 'measurement-2', colonyId: 'colony-1', method: 'NATURAL_MITE_FALL', calculatedValue: 5, miteCount: null, } as never); expect(prisma.task.create).not.toHaveBeenCalled(); }); it('ignores a raw board count without a per-day rate', async () => { // 15 mites over an unknown number of days says nothing about the // per-day threshold - comparing them would be a unit error. prisma.automationRule.findUnique.mockResolvedValue(varroaRule); await service.evaluateVarroaMeasurement({ id: 'measurement-3', colonyId: 'colony-1', method: 'NATURAL_MITE_FALL', calculatedValue: null, miteCount: 15, } as never); expect(prisma.task.create).not.toHaveBeenCalled(); }); it('ignores sample-based methods, whose values use another unit', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); await service.evaluateVarroaMeasurement({ id: 'measurement-4', colonyId: 'colony-1', method: 'WASHING_METHOD', calculatedValue: 30, miteCount: 30, } as never); expect(prisma.task.create).not.toHaveBeenCalled(); }); it('adds no second task while one for the same colony is still open', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); prisma.task.findFirst.mockResolvedValue({ id: 'open-task' }); await service.evaluateVarroaMeasurement({ id: 'measurement-5', colonyId: 'colony-1', method: 'NATURAL_MITE_FALL', calculatedValue: 25, miteCount: 25, } as never); expect(prisma.task.create).not.toHaveBeenCalled(); expect(notifications.record).not.toHaveBeenCalled(); expect(prisma.task.findFirst).toHaveBeenCalledWith({ where: { sourceAutomationTrigger: 'VARROA_THRESHOLD', relatedType: 'COLONY', relatedId: 'colony-1', status: { in: ['OPEN', 'IN_PROGRESS'] }, }, select: { id: true }, }); }); it('checks for an open task and creates one under a lock on the rule row', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); await service.evaluateVarroaMeasurement({ id: 'measurement-6', colonyId: 'colony-1', method: 'NATURAL_MITE_FALL', calculatedValue: 25, miteCount: 25, } as never); expect(prisma.$transaction).toHaveBeenCalledTimes(1); const [sql, ...values] = prisma.$queryRaw.mock.calls[0] as [ TemplateStringsArray, ...unknown[], ]; expect(sql.join('?')).toMatch( /SELECT id FROM "AutomationRule" WHERE id = \? FOR UPDATE/, ); expect(values).toEqual(['rule-3']); // Lock first, then check, then create - all in that one transaction. const [lockOrder] = prisma.$queryRaw.mock.invocationCallOrder; const [checkOrder] = prisma.task.findFirst.mock.invocationCallOrder; const [createOrder] = prisma.task.create.mock.invocationCallOrder; expect(lockOrder).toBeLessThan(checkOrder); expect(checkOrder).toBeLessThan(createOrder); // Webhook and warning follow the commit, outside the lock. const [dispatchOrder] = webhooks.dispatch.mock.invocationCallOrder; expect(dispatchOrder).toBeGreaterThan(createOrder); }); }); describe('evaluateSwarmControlCheck', () => { const swarmRule = { id: 'rule-4', triggerType: 'SWARM_MOOD_HIGH', active: true, config: {}, actionTemplate: 'Schwarmkontrolle dringend', }; it('creates a task when swarm mood is HIGH', async () => { prisma.automationRule.findUnique.mockResolvedValue(swarmRule); await service.evaluateSwarmControlCheck({ id: 'check-1', colonyId: 'colony-1', swarmMood: 'HIGH', } as never); expect(prisma.task.create).toHaveBeenCalledTimes(1); }); it('does not trigger for a low swarm mood', async () => { prisma.automationRule.findUnique.mockResolvedValue(swarmRule); await service.evaluateSwarmControlCheck({ id: 'check-2', colonyId: 'colony-1', swarmMood: 'LOW', } as never); expect(prisma.task.create).not.toHaveBeenCalled(); }); }); describe('evaluateColonyHealth', () => { const healthRule = { id: 'rule-5', triggerType: 'COLONY_HEALTH_CRITICAL', active: true, config: {}, actionTemplate: 'Kontrolle erforderlich', }; it('creates a task on the transition into CRITICAL', async () => { prisma.automationRule.findUnique.mockResolvedValue(healthRule); await service.evaluateColonyHealth( { id: 'colony-1', healthStatus: 'AVERAGE' } as never, { id: 'colony-1', healthStatus: 'CRITICAL' } as never, ); expect(prisma.task.create).toHaveBeenCalledTimes(1); }); it('does not re-trigger when already CRITICAL before the update', async () => { prisma.automationRule.findUnique.mockResolvedValue(healthRule); await service.evaluateColonyHealth( { id: 'colony-1', healthStatus: 'CRITICAL' } as never, { id: 'colony-1', healthStatus: 'CRITICAL' } as never, ); expect(prisma.task.create).not.toHaveBeenCalled(); }); }); describe('evaluateStockItem', () => { const stockRule = { id: 'rule-6', triggerType: 'STOCK_ITEM_LOW', active: true, config: {}, actionTemplate: 'Nachbestellung prüfen', }; it('creates a task when quantity drops below minThreshold', async () => { prisma.automationRule.findUnique.mockResolvedValue(stockRule); await service.evaluateStockItem({ id: 'item-1', quantity: 2, minThreshold: 5, } as never); expect(prisma.task.create).toHaveBeenCalledTimes(1); }); it('does not trigger when minThreshold is not set', async () => { prisma.automationRule.findUnique.mockResolvedValue(stockRule); await service.evaluateStockItem({ id: 'item-2', quantity: 2, minThreshold: null, } as never); expect(prisma.task.create).not.toHaveBeenCalled(); }); }); describe('evaluateRawMaterialLow', () => { const rawRule = { id: 'rule-7', triggerType: 'RAW_MATERIAL_LOW', active: true, config: { minGrams: { BEESWAX: 1000 } }, actionTemplate: 'Rohstoff unter Mindestmenge', }; it('creates one task per kind when the kind drops below its minimum', async () => { prisma.automationRule.findUnique.mockResolvedValue(rawRule); await service.evaluateRawMaterialLow('BEESWAX', 400); const [createArgs] = prisma.task.create.mock.calls[0] as [unknown]; expect(createArgs).toMatchObject({ data: { relatedType: 'GENERAL', relatedId: 'BEESWAX', sourceAutomationTrigger: 'RAW_MATERIAL_LOW', description: 'Bienenwachs: noch 400 g auf Lager, Mindestmenge 1000 g.', }, }); }); it('does not trigger at or above the minimum', async () => { prisma.automationRule.findUnique.mockResolvedValue(rawRule); await service.evaluateRawMaterialLow('BEESWAX', 1000); expect(prisma.task.create).not.toHaveBeenCalled(); }); it('does not trigger for a kind without a minimum', async () => { prisma.automationRule.findUnique.mockResolvedValue(rawRule); await service.evaluateRawMaterialLow('PROPOLIS', 0); expect(prisma.task.create).not.toHaveBeenCalled(); }); it('does not trigger when the rule is inactive', async () => { prisma.automationRule.findUnique.mockResolvedValue({ ...rawRule, active: false, }); await service.evaluateRawMaterialLow('BEESWAX', 0); expect(prisma.task.create).not.toHaveBeenCalled(); }); }); describe('update', () => { it('records an audit log entry with before/after data', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); prisma.automationRule.update.mockResolvedValue({ ...varroaRule, active: false, }); await service.update('rule-3', { active: false }, 'user-1'); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'AutomationRule', entityId: 'rule-3', action: 'UPDATE', }), ); }); it('clears the config on null so the rule falls back to its defaults', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); prisma.automationRule.update.mockResolvedValue({ ...varroaRule, config: null, }); await service.update('rule-3', { config: null as never }, 'user-1'); const [[updateArg]] = prisma.automationRule.update.mock.calls as [ [{ data: { config?: unknown } }], ]; // A Json column is cleared with DbNull - a plain null fails the write. expect(updateArg.data.config).toBe(Prisma.DbNull); }); it('leaves the config untouched when it is omitted', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); prisma.automationRule.update.mockResolvedValue(varroaRule); await service.update('rule-3', { active: true }, 'user-1'); const [[updateArg]] = prisma.automationRule.update.mock.calls as [ [{ data: { config?: unknown } }], ]; expect(updateArg.data.config).toBeUndefined(); }); }); describe('generateNumber', () => { it('returns null when the rule is inactive', async () => { prisma.automationRule.findUnique.mockResolvedValue({ id: 'rule-4', triggerType: 'HONEY_BATCH_NUMBER_FORMAT', active: false, config: { format: 'WH-{YYYY}-{SEQ:4}' }, sequenceValue: 0, }); const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT'); expect(result).toBeNull(); expect(prisma.automationRule.update).not.toHaveBeenCalled(); }); it('returns null when no format is configured', async () => { prisma.automationRule.findUnique.mockResolvedValue({ id: 'rule-4', triggerType: 'HONEY_BATCH_NUMBER_FORMAT', active: true, config: {}, sequenceValue: 0, }); const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT'); expect(result).toBeNull(); }); it('atomically increments sequenceValue and formats the number', async () => { prisma.automationRule.findUnique.mockResolvedValue({ id: 'rule-4', triggerType: 'HONEY_BATCH_NUMBER_FORMAT', active: true, config: { format: 'WH-{YYYY}-{SEQ:4}' }, sequenceValue: 0, }); prisma.automationRule.update.mockResolvedValue({ sequenceValue: 7 }); const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT'); expect(prisma.automationRule.update).toHaveBeenCalledWith({ where: { triggerType: 'HONEY_BATCH_NUMBER_FORMAT' }, data: { sequenceValue: { increment: 1 } }, select: { sequenceValue: true }, }); expect(result).toBe(`WH-${new Date().getFullYear()}-0007`); }); const rawFormatRule = { id: 'rule-8', triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT', active: true, config: { format: '{ART}-{YYYY}-{SEQ:4}' }, sequenceValue: 0, }; it('counts per scope when the format tells the scopes apart', async () => { prisma.automationRule.findUnique.mockResolvedValue(rawFormatRule); prisma.automationSequence.upsert.mockResolvedValue({ value: 3 }); const result = await service.generateNumber( 'RAW_MATERIAL_LOT_NUMBER_FORMAT', { scope: 'PROPOLIS', vars: { ART: 'PR' } }, ); expect(prisma.automationSequence.upsert).toHaveBeenCalledWith({ where: { triggerType_scope: { triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT', scope: 'PROPOLIS', }, }, create: { triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT', scope: 'PROPOLIS', value: 1, }, update: { value: { increment: 1 } }, select: { value: true }, }); expect(prisma.automationRule.update).not.toHaveBeenCalled(); expect(result).toBe(`PR-${new Date().getFullYear()}-0003`); }); it('keeps one shared counter when the format has no {ART}', async () => { prisma.automationRule.findUnique.mockResolvedValue({ ...rawFormatRule, config: { format: 'RS-{YYYY}-{SEQ:4}' }, }); prisma.automationRule.update.mockResolvedValue({ sequenceValue: 5 }); const result = await service.generateNumber( 'RAW_MATERIAL_LOT_NUMBER_FORMAT', { scope: 'BEESWAX', vars: { ART: 'WA' } }, ); expect(prisma.automationSequence.upsert).not.toHaveBeenCalled(); expect(result).toBe(`RS-${new Date().getFullYear()}-0005`); }); it('retries once when two first uses of a scope collide', async () => { prisma.automationRule.findUnique.mockResolvedValue(rawFormatRule); prisma.automationSequence.upsert .mockRejectedValueOnce({ code: 'P2002' }) .mockResolvedValueOnce({ value: 2 }); const result = await service.generateNumber( 'RAW_MATERIAL_LOT_NUMBER_FORMAT', { scope: 'BEESWAX', vars: { ART: 'WA' } }, ); expect(prisma.automationSequence.upsert).toHaveBeenCalledTimes(2); expect(result).toBe(`WA-${new Date().getFullYear()}-0002`); }); }); describe('resetSequence', () => { it('sets sequenceValue back to 0 and records an audit log entry', async () => { prisma.automationRule.findUnique.mockResolvedValue({ id: 'rule-4', triggerType: 'HONEY_BATCH_NUMBER_FORMAT', active: true, config: { format: 'WH-{YYYY}-{SEQ:4}' }, sequenceValue: 12, }); prisma.automationRule.update.mockResolvedValue({ id: 'rule-4', sequenceValue: 0, }); await service.resetSequence('rule-4', 'user-1'); expect(prisma.automationRule.update).toHaveBeenCalledWith({ where: { id: 'rule-4' }, data: { sequenceValue: 0, sequences: { deleteMany: {} } }, }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'AutomationRule', entityId: 'rule-4', action: 'UPDATE', userId: 'user-1', }), ); }); }); });