import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { AutomationService } from './automation.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { SuggestionsService } from '../suggestions/suggestions.service'; import { AutomationTriggerType, Prisma, RawMaterialKind, } from '../../generated/prisma'; type Rule = { id: string; triggerType: string; active: boolean; config: unknown; actionTemplate: string; }; type RuleLookup = { where: { triggerType?: string; id?: string } }; describe('AutomationService', () => { let service: AutomationService; // The transaction client: evaluations read and write only through it. let tx: { automationRule: { createMany: jest.Mock; findMany: jest.Mock; findUnique: jest.Mock; findUniqueOrThrow: jest.Mock; update: jest.Mock; }; colony: { findUnique: jest.Mock }; suggestion: { findFirst: jest.Mock }; inspection: { findFirst: jest.Mock }; treatment: { findMany: jest.Mock }; stockMovement: { findFirst: jest.Mock }; rawMaterialLot: { findFirst: jest.Mock }; $queryRaw: jest.Mock; }; let prisma: { automationRule: { createMany: jest.Mock; findMany: jest.Mock; findUnique: jest.Mock; update: jest.Mock; }; automationSequence: { upsert: jest.Mock }; $transaction: jest.Mock; // Only the root client has $connect - assertTransactionClient tells // the two apart by it. $connect: jest.Mock; }; let auditLog: { record: jest.Mock }; let suggestions: { proposeIn: jest.Mock; refreshIn: jest.Mock; resolveIn: jest.Mock; }; // The rule rows of the transaction, by trigger type. let rules: Map; const queenlessRule: Rule = { id: 'rule-1', triggerType: 'QUEENLESS_NO_EGGS', active: true, config: {}, actionTemplate: 'Kontrolle empfohlen', }; const feedRule: Rule = { id: 'rule-2', triggerType: 'FEED_STATUS_CRITICAL', active: true, config: {}, actionTemplate: 'Fütterung erforderlich', }; const varroaRule: Rule = { id: 'rule-3', triggerType: 'VARROA_THRESHOLD', active: true, config: { thresholdMitesPerDay: 10 }, actionTemplate: 'Behandlung prüfen', }; const queenlessInspection = { id: 'inspection-1', colonyId: 'colony-1', date: new Date('2026-05-05T00:00:00.000Z'), queenSeen: false, eggsPresent: false, feedStatus: null, }; const asTx = () => tx as unknown as Prisma.TransactionClient; // The inspection rules decide on the colony's latest inspection: the one // under test is it. const evaluateInspection = (inspection: never) => { tx.inspection.findFirst.mockResolvedValue(inspection); return service.evaluateInspectionIn(asTx(), inspection); }; const evaluateInspectionCorrection = (inspection: never) => { tx.inspection.findFirst.mockResolvedValue(inspection); return service.evaluateInspectionCorrectionIn(asTx(), inspection); }; const withRules = (...list: Rule[]) => { for (const rule of list) { rules.set(rule.triggerType, rule); } }; const ruleOf = ({ where }: RuleLookup): Rule | undefined => where.triggerType === undefined ? [...rules.values()].find((rule) => rule.id === where.id) : rules.get(where.triggerType); beforeEach(() => { rules = new Map(); tx = { automationRule: { createMany: jest.fn().mockResolvedValue({ count: 0 }), findMany: jest.fn().mockResolvedValue([]), // No row: the built-in setting applies. findUnique: jest.fn((query: RuleLookup) => Promise.resolve(ruleOf(query) ?? null), ), findUniqueOrThrow: jest.fn((query: RuleLookup) => { const rule = ruleOf(query); return rule ? Promise.resolve(rule) : Promise.reject(new Error('no rule row')); }), update: jest.fn(), }, colony: { findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE', colonyType: 'PRODUCTION_COLONY', }), }, suggestion: { findFirst: jest.fn().mockResolvedValue(null) }, inspection: { findFirst: jest.fn().mockResolvedValue(null) }, treatment: { findMany: jest.fn().mockResolvedValue([]) }, stockMovement: { findFirst: jest.fn().mockResolvedValue(null) }, rawMaterialLot: { findFirst: jest.fn().mockResolvedValue(null) }, $queryRaw: jest.fn().mockResolvedValue([{ id: 'locked' }]), }; prisma = { automationRule: { createMany: jest.fn().mockResolvedValue({ count: 0 }), findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), }, automationSequence: { upsert: jest.fn() }, $transaction: jest.fn((work: (client: unknown) => unknown) => work(tx)), $connect: jest.fn(), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; suggestions = { proposeIn: jest.fn().mockResolvedValue({ id: 'suggestion-1' }), refreshIn: jest.fn().mockResolvedValue(undefined), resolveIn: jest.fn().mockResolvedValue(1), }; service = new AutomationService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, suggestions as unknown as SuggestionsService, ); }); it('proposes through SuggestionsService and sends no task or notification itself', () => { expect(Reflect.getMetadata('design:paramtypes', AutomationService)).toEqual( [PrismaService, AuditLogService, SuggestionsService], ); }); it('findById throws NotFoundException when missing', async () => { prisma.automationRule.findUnique.mockResolvedValue(null); await expect(service.findById('missing')).rejects.toBeInstanceOf( NotFoundException, ); }); it('findAll seeds every missing default rule in one statement before returning them', async () => { prisma.automationRule.findMany.mockResolvedValue([ queenlessRule, varroaRule, feedRule, ]); const result = await service.findAll(); expect(prisma.automationRule.createMany).toHaveBeenCalledTimes(1); const [[seed]] = prisma.automationRule.createMany.mock.calls as [ [{ data: { triggerType: string }[]; skipDuplicates: boolean }], ]; // Existing rules keep their settings: duplicates are skipped, not updated. expect(seed.skipDuplicates).toBe(true); expect(seed.data.map((rule) => rule.triggerType).sort()).toEqual( [...Object.values(AutomationTriggerType)].sort(), ); 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('legacy wrappers (post-commit evaluation)', () => { let errorSpy: jest.SpyInstance; beforeEach(() => { errorSpy = jest .spyOn(Logger.prototype, 'error') .mockImplementation(() => undefined); }); afterEach(() => { errorSpy.mockRestore(); }); const check = { id: 'check-1', colonyId: 'colony-1', date: new Date('2026-05-05T00:00:00.000Z'), swarmMood: 'HIGH', }; const item = { id: 'item-1', quantity: 1, minThreshold: 5 }; // [legacy method, log subject, call, core it delegates to, core args after tx] const wrappers: [ string, string, (s: AutomationService) => Promise, keyof AutomationService, unknown[], ][] = [ [ 'evaluateSwarmControlCheck', 'SwarmControlCheck check-1', (s) => s.evaluateSwarmControlCheck(check as never), 'evaluateSwarmControlCheckIn', [check], ], [ 'evaluateStockItem', 'StockItem item-1', (s) => s.evaluateStockItem(item as never), 'evaluateStockItemIn', [item], ], ]; it.each(wrappers)( '%s runs the shared core in one transaction', async (_name, _subject, evaluate, core, args) => { const coreSpy = jest .spyOn(service, core as 'evaluateInspectionIn') .mockResolvedValue(undefined); await evaluate(service); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(coreSpy).toHaveBeenCalledWith(tx, ...args); }, ); it.each(wrappers)( '%s never throws and logs the failure with the record', async (_name, subject, evaluate) => { tx.automationRule.findUnique.mockRejectedValue(new Error('db down')); 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.each(wrappers)( '%s never throws when the transaction cannot even start', async (_name, subject, evaluate) => { prisma.$transaction.mockRejectedValue(new Error('pool exhausted')); await expect(evaluate(service)).resolves.toBeUndefined(); const [message] = errorSpy.mock.calls[0] as [string]; expect(message).toContain(subject); }, ); it('still throws from generateNumber, which runs before the write', async () => { prisma.automationRule.createMany.mockRejectedValue(new Error('db down')); await expect( service.generateNumber('HONEY_BATCH_NUMBER_FORMAT'), ).rejects.toThrow('db down'); expect(errorSpy).not.toHaveBeenCalled(); }); it('leaves no suggestion or audit entry when the audit write fails (one rollback)', async () => { // A transaction fake: writes through tx are staged and kept only when // the work resolves - like Postgres, a rejected callback keeps none. const committed: string[] = []; let staged: string[] = []; prisma.$transaction.mockImplementation( async (work: (client: unknown) => Promise) => { staged = []; const result = await work(tx); committed.push(...staged); return result; }, ); withRules({ ...queenlessRule, triggerType: 'SWARM_MOOD_HIGH' }); suggestions.proposeIn.mockImplementation((client: unknown) => { staged.push(client === tx ? 'suggestion' : 'suggestion outside tx'); return Promise.resolve({ id: 'suggestion-1' }); }); auditLog.record.mockRejectedValue(new Error('audit down')); await service.evaluateSwarmControlCheck({ id: 'check-1', colonyId: 'colony-1', date: new Date('2026-05-05T00:00:00.000Z'), swarmMood: 'HIGH', } as never); expect(suggestions.proposeIn).toHaveBeenCalledTimes(1); expect(committed).toEqual([]); expect(errorSpy).toHaveBeenCalledTimes(1); const [message] = errorSpy.mock.calls[0] as [string]; expect(message).toContain('audit down'); }); }); describe('proposeForRuleIn', () => { const target = { relatedType: 'COLONY' as const, relatedId: 'colony-1', colonyId: 'colony-1', intent: 'QUEEN_CHECK' as const, reasonCode: 'QUEENLESS_NO_EGGS', evidence: { colonyId: 'colony-1', inspectionId: 'inspection-1', inspectedOn: '2026-05-05', }, anchor: 'inspection-1', requiredPermission: 'inspections:read' as const, }; beforeEach(() => { withRules(queenlessRule); }); it('proposeForRuleIn writes a suggestion under the rule lock and an audit entry with suggestionId', async () => { const result = await service.proposeForRuleIn( asTx(), 'QUEENLESS_NO_EGGS', { ...target, dueDate: '2026-05-12', sourceEventId: 'event-1' }, ); expect(result).toEqual({ id: 'suggestion-1' }); expect(suggestions.proposeIn).toHaveBeenCalledWith(tx, { ruleKey: 'QUEENLESS_NO_EGGS', source: 'RULE', mode: 'PLAN', intent: 'QUEEN_CHECK', priority: 'HIGH', relatedType: 'COLONY', relatedId: 'colony-1', colonyId: 'colony-1', reasonCode: 'QUEENLESS_NO_EGGS', evidence: target.evidence, requiredPermission: 'inspections:read', dueDate: '2026-05-12', anchor: 'inspection-1', sourceEventId: 'event-1', // The default setting of the rule. notify: true, }); expect(auditLog.record).toHaveBeenCalledWith( { entityType: 'AutomationRule', entityId: 'rule-1', action: 'CREATE', afterData: { suggestionId: 'suggestion-1', relatedType: 'COLONY', relatedId: 'colony-1', triggerType: 'QUEENLESS_NO_EGGS', }, }, tx, ); // The rule row is locked in this transaction, after the colony. const lockCalls = tx.$queryRaw.mock.calls as [Prisma.Sql][]; const lockedTables = lockCalls.map( ([query]) => /FROM "(\w+)"/.exec(query.sql)?.[1], ); expect(lockedTables).toEqual(['Colony', 'AutomationRule']); expect(prisma.$transaction).not.toHaveBeenCalled(); }); it('passes the notify setting of the rule on', async () => { withRules({ ...queenlessRule, config: { notify: false } }); await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); expect(suggestions.proposeIn).toHaveBeenCalledWith( tx, expect.objectContaining({ notify: false }), ); }); it('has no date unless the caller gives one', async () => { await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); expect(suggestions.proposeIn).toHaveBeenCalledWith( tx, expect.objectContaining({ dueDate: null, sourceEventId: null }), ); }); it('writes no audit entry when nothing was proposed', async () => { suggestions.proposeIn.mockResolvedValue(null); await expect( service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target), ).resolves.toBeNull(); expect(auditLog.record).not.toHaveBeenCalled(); }); it('proposes nothing for an inactive rule', async () => { withRules({ ...queenlessRule, active: false }); await expect( service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target), ).resolves.toBeNull(); expect(suggestions.proposeIn).not.toHaveBeenCalled(); }); it('proposes nothing for a colony that ended or is gone', async () => { tx.colony.findUnique.mockResolvedValue({ status: 'DISSOLVED' }); await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); tx.colony.findUnique.mockResolvedValue(null); await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); tx.$queryRaw.mockResolvedValue([]); await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); expect(suggestions.proposeIn).not.toHaveBeenCalled(); expect(auditLog.record).not.toHaveBeenCalled(); }); it('locks no colony for a target without one', async () => { withRules({ ...queenlessRule, triggerType: 'STOCK_ITEM_LOW' }); await service.proposeForRuleIn(asTx(), 'STOCK_ITEM_LOW', { ...target, relatedType: 'STORAGE', relatedId: 'item-1', colonyId: null, }); expect(tx.colony.findUnique).not.toHaveBeenCalled(); expect(suggestions.proposeIn).toHaveBeenCalledTimes(1); }); it('seeds a missing rule row through the lock, never through the root client', async () => { await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); expect(tx.automationRule.createMany).toHaveBeenCalledWith( expect.objectContaining({ skipDuplicates: true }), ); expect(prisma.automationRule.createMany).not.toHaveBeenCalled(); }); it('refuses the root client: the lock needs a transaction', async () => { await expect( service.proposeForRuleIn( prisma as unknown as Prisma.TransactionClient, 'QUEENLESS_NO_EGGS', target, ), ).rejects.toThrow('needs the transaction client'); expect(suggestions.proposeIn).not.toHaveBeenCalled(); }); it('lets a failing proposeIn propagate and writes nothing else', async () => { suggestions.proposeIn.mockRejectedValue(new Error('suggestions down')); await expect( service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target), ).rejects.toThrow('suggestions down'); expect(auditLog.record).not.toHaveBeenCalled(); }); }); describe('the …In cores', () => { let propose: jest.SpyInstance; beforeEach(() => { propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); }); it('refuse the root client', async () => { await expect( service.evaluateInspectionIn( prisma as unknown as Prisma.TransactionClient, queenlessInspection as never, ), ).rejects.toThrow('needs the transaction client'); expect(propose).not.toHaveBeenCalled(); }); it('read the rules on the given transaction client and write no seed', async () => { await evaluateInspection(queenlessInspection as never); expect(tx.automationRule.findUnique).toHaveBeenCalled(); expect(prisma.automationRule.createMany).not.toHaveBeenCalled(); expect(prisma.automationRule.findMany).not.toHaveBeenCalled(); }); }); describe('evaluateInspectionIn', () => { let propose: jest.SpyInstance; beforeEach(() => { propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); }); it('proposes a queen check anchored on the inspection when the queen was not seen and no eggs are present', async () => { withRules(queenlessRule); await evaluateInspection(queenlessInspection as never); expect(propose).toHaveBeenCalledTimes(1); expect(propose).toHaveBeenCalledWith(tx, 'QUEENLESS_NO_EGGS', { relatedType: 'COLONY', relatedId: 'colony-1', colonyId: 'colony-1', intent: 'QUEEN_CHECK', reasonCode: 'QUEENLESS_NO_EGGS', evidence: { colonyId: 'colony-1', inspectionId: 'inspection-1', inspectedOn: '2026-05-05', }, anchor: 'inspection-1', requiredPermission: 'inspections:read', dueDate: null, }); }); it('dates the suggestion from the inspection day when the rule has dueInDays', async () => { withRules({ ...queenlessRule, config: { dueInDays: 3 } }); await evaluateInspection(queenlessInspection as never); expect(propose).toHaveBeenCalledWith( tx, 'QUEENLESS_NO_EGGS', expect.objectContaining({ dueDate: '2026-05-08' }), ); }); it('does not trigger when the queen was seen', async () => { withRules(queenlessRule); await evaluateInspection({ ...queenlessInspection, queenSeen: true, } as never); expect(propose).not.toHaveBeenCalled(); }); it('proposes feeding when the feed status is CRITICAL', async () => { withRules(feedRule); await evaluateInspection({ ...queenlessInspection, id: 'inspection-3', queenSeen: true, eggsPresent: true, feedStatus: 'CRITICAL', } as never); expect(propose).toHaveBeenCalledTimes(1); expect(propose).toHaveBeenCalledWith( tx, 'FEED_STATUS_CRITICAL', expect.objectContaining({ intent: 'FEED', reasonCode: 'FEED_STATUS_CRITICAL', anchor: 'inspection-3', requiredPermission: 'inspections:read', }), ); }); it('proposes both findings of one inspection', async () => { withRules(queenlessRule, feedRule); await evaluateInspection({ ...queenlessInspection, feedStatus: 'CRITICAL', } as never); expect(propose).toHaveBeenCalledTimes(2); }); it('does not trigger a deactivated rule', async () => { withRules( { ...queenlessRule, active: false }, { ...feedRule, active: false }, ); await evaluateInspection({ ...queenlessInspection, feedStatus: 'CRITICAL', } as never); expect(propose).not.toHaveBeenCalled(); }); it('reads no rule when there is no finding', async () => { await evaluateInspection({ ...queenlessInspection, queenSeen: true, } as never); expect(tx.automationRule.findUnique).not.toHaveBeenCalled(); }); }); describe('open inspection suggestions follow the latest inspection', () => { let propose: jest.SpyInstance; const latestOf = (overrides: Record = {}) => ({ ...queenlessInspection, id: 'inspection-2', date: new Date('2026-05-12T00:00:00.000Z'), createdAt: new Date('2026-05-12T08:00:00.000Z'), queenSeen: true, eggsPresent: true, ...overrides, }) as never; const lockedTables = () => (tx.$queryRaw.mock.calls as [Prisma.Sql][]).map( ([query]) => /FROM "(\w+)"/.exec(query.sql)?.[1], ); beforeEach(() => { propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); withRules(queenlessRule, feedRule); tx.suggestion.findFirst.mockImplementation( ({ where }: { where: { ruleKey: string } }) => Promise.resolve( where.ruleKey === 'QUEENLESS_NO_EGGS' ? { id: 'open-1' } : null, ), ); }); it('locks the colony first, reads the latest inspection and the open suggestion under it, then locks the suggestion', async () => { await evaluateInspection(latestOf()); expect(lockedTables()).toEqual(['Colony', 'Suggestion']); const [colonyLock, suggestionLock] = tx.$queryRaw.mock.invocationCallOrder; const [latestRead] = tx.inspection.findFirst.mock.invocationCallOrder; const [openRead] = tx.suggestion.findFirst.mock.invocationCallOrder; expect(colonyLock).toBeLessThan(latestRead); expect(latestRead).toBeLessThan(openRead); expect(openRead).toBeLessThan(suggestionLock); }); it('takes the latest inspection by date, then creation time, then id', async () => { await evaluateInspection(latestOf()); expect(tx.inspection.findFirst).toHaveBeenCalledWith({ where: { colonyId: 'colony-1' }, orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], }); }); it('looks for the open suggestion of each rule for the colony', async () => { await evaluateInspection(latestOf()); expect(tx.suggestion.findFirst).toHaveBeenCalledWith({ where: { ruleKey: 'QUEENLESS_NO_EGGS', colonyId: 'colony-1', status: 'OPEN', }, select: { id: true }, }); }); it('resolves it when the latest inspection no longer shows the finding', async () => { await evaluateInspection(latestOf()); expect(suggestions.resolveIn).toHaveBeenCalledWith( tx, ['open-1'], 'Bedingung entfällt', ); expect(propose).not.toHaveBeenCalled(); }); it('refreshes it with the latest inspection when the finding persists', async () => { await evaluateInspection( latestOf({ queenSeen: false, eggsPresent: false }), ); expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'open-1', { evidence: { colonyId: 'colony-1', inspectionId: 'inspection-2', inspectedOn: '2026-05-12', }, dueDate: null, priority: 'HIGH', expiresAt: null, }); expect(suggestions.resolveIn).not.toHaveBeenCalled(); expect(propose).not.toHaveBeenCalled(); }); it('recomputes the due date from dueInDays when it refreshes', async () => { withRules({ ...queenlessRule, config: { dueInDays: 4 } }); await evaluateInspection( latestOf({ queenSeen: false, eggsPresent: false }), ); expect(suggestions.refreshIn).toHaveBeenCalledWith( tx, 'open-1', expect.objectContaining({ dueDate: '2026-05-16' }), ); }); it('is idempotent: a re-delivery refreshes the same evidence again', async () => { const inspection = latestOf({ queenSeen: false, eggsPresent: false }); await evaluateInspection(inspection); await evaluateInspection(inspection); const [first, second] = suggestions.refreshIn.mock.calls as unknown[][]; expect(second).toEqual(first); expect(suggestions.resolveIn).not.toHaveBeenCalled(); }); it('proposes nothing and changes nothing when the event inspection is not the latest (late, retried or backdated)', async () => { tx.inspection.findFirst.mockResolvedValue(latestOf({ id: 'newer' })); await service.evaluateInspectionIn( asTx(), latestOf({ id: 'older', queenSeen: false, eggsPresent: false, date: new Date('2026-05-01T00:00:00.000Z'), }), ); await service.evaluateInspectionCorrectionIn(asTx(), latestOf()); expect(propose).not.toHaveBeenCalled(); expect(suggestions.resolveIn).not.toHaveBeenCalled(); expect(suggestions.refreshIn).not.toHaveBeenCalled(); expect(tx.suggestion.findFirst).not.toHaveBeenCalled(); }); it('does nothing when the colony is gone or has no inspection any more', async () => { tx.$queryRaw.mockResolvedValueOnce([]); await evaluateInspection(latestOf()); tx.inspection.findFirst.mockResolvedValue(null); await service.evaluateInspectionIn(asTx(), latestOf()); expect(suggestions.resolveIn).not.toHaveBeenCalled(); expect(suggestions.refreshIn).not.toHaveBeenCalled(); }); it('the correction of the latest inspection resolves it when the finding is gone', async () => { await evaluateInspectionCorrection(latestOf()); expect(suggestions.resolveIn).toHaveBeenCalledTimes(1); }); it('keeps it OPEN when the corrected latest inspection still shows the finding', async () => { await evaluateInspectionCorrection( latestOf({ queenSeen: false, eggsPresent: false }), ); expect(suggestions.resolveIn).not.toHaveBeenCalled(); expect(suggestions.refreshIn).toHaveBeenCalledTimes(1); }); it('a correction never proposes', async () => { tx.suggestion.findFirst.mockResolvedValue(null); await evaluateInspectionCorrection( latestOf({ queenSeen: false, eggsPresent: false }), ); expect(propose).not.toHaveBeenCalled(); }); it('leaves open suggestions of an inactive rule untouched', async () => { withRules({ ...queenlessRule, active: false }); await evaluateInspection(latestOf()); await evaluateInspection( latestOf({ queenSeen: false, eggsPresent: false }), ); expect(suggestions.resolveIn).not.toHaveBeenCalled(); expect(suggestions.refreshIn).not.toHaveBeenCalled(); }); it('follows the feed rule the same way', async () => { tx.suggestion.findFirst.mockImplementation( ({ where }: { where: { ruleKey: string } }) => Promise.resolve( where.ruleKey === 'FEED_STATUS_CRITICAL' ? { id: 'open-feed' } : null, ), ); await evaluateInspection(latestOf()); expect(suggestions.resolveIn).toHaveBeenCalledWith( tx, ['open-feed'], 'Bedingung entfällt', ); }); }); describe('evaluateVarroaMeasurementIn', () => { let propose: jest.SpyInstance; const measurement = ( calculatedValue: number | null, overrides: Record = {}, ) => ({ id: 'measurement-1', colonyId: 'colony-1', date: new Date('2026-08-10T00:00:00.000Z'), method: 'NATURAL_MITE_FALL', calculatedValue, miteCount: null, ...overrides, }) as never; beforeEach(() => { propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); withRules(varroaRule); }); it('proposes a treatment check when the mite fall is red', async () => { await service.evaluateVarroaMeasurementIn(asTx(), measurement(12.4)); expect(propose).toHaveBeenCalledWith(tx, 'VARROA_THRESHOLD', { relatedType: 'COLONY', relatedId: 'colony-1', colonyId: 'colony-1', intent: 'VARROA_TREAT', reasonCode: 'VARROA_RED', evidence: { colonyId: 'colony-1', measurementId: 'measurement-1', measuredOn: '2026-08-10', value: 12.4, redAbove: 10, unit: 'MITES_PER_DAY', }, anchor: 'measurement-1', requiredPermission: 'varroa:read', dueDate: null, }); }); it('10.0 mites per day is yellow and proposes nothing', async () => { await service.evaluateVarroaMeasurementIn(asTx(), measurement(10)); expect(propose).not.toHaveBeenCalled(); }); it('proposes nothing for a deactivated rule', async () => { withRules({ ...varroaRule, active: false }); await service.evaluateVarroaMeasurementIn(asTx(), measurement(50)); expect(propose).not.toHaveBeenCalled(); }); it('proposes nothing inside the post-treatment window', async () => { tx.treatment.findMany.mockResolvedValue([ { date: new Date('2026-08-05T00:00:00.000Z'), startDate: null, endDate: null, }, ]); await service.evaluateVarroaMeasurementIn(asTx(), measurement(50)); expect(tx.treatment.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { colonyId: 'colony-1' } }), ); expect(propose).not.toHaveBeenCalled(); }); it('proposes again once the recheck day has passed', async () => { tx.treatment.findMany.mockResolvedValue([ { date: new Date('2026-07-10T00:00:00.000Z'), startDate: null, endDate: null, }, ]); await service.evaluateVarroaMeasurementIn(asTx(), measurement(50)); expect(propose).toHaveBeenCalledTimes(1); }); it('halves the limits of a young colony', async () => { tx.colony.findUnique.mockResolvedValue({ status: 'ACTIVE', colonyType: 'YOUNG_COLONY', }); await service.evaluateVarroaMeasurementIn(asTx(), measurement(6)); expect(propose).toHaveBeenCalledWith( tx, 'VARROA_THRESHOLD', expect.objectContaining({ evidence: expect.objectContaining({ redAbove: 5 }) as unknown, }), ); }); it('rates a percentage method with its summer band', async () => { await service.evaluateVarroaMeasurementIn( asTx(), measurement(6, { method: 'POWDERED_SUGAR' }), ); expect(propose).toHaveBeenCalledWith( tx, 'VARROA_THRESHOLD', expect.objectContaining({ evidence: expect.objectContaining({ value: 6, redAbove: 5, unit: 'PERCENT', }) as unknown, }), ); }); it('proposes nothing for a method without a band in that month', async () => { await service.evaluateVarroaMeasurementIn( asTx(), measurement(30, { method: 'WASHING_METHOD', date: new Date('2026-04-10T00:00:00.000Z'), }), ); expect(propose).not.toHaveBeenCalled(); }); it('ignores a raw board count without a per-day rate', async () => { await service.evaluateVarroaMeasurementIn( asTx(), measurement(null, { miteCount: 15 }), ); expect(propose).not.toHaveBeenCalled(); }); it('proposes nothing for a colony that is gone', async () => { tx.colony.findUnique.mockResolvedValue(null); await service.evaluateVarroaMeasurementIn(asTx(), measurement(50)); expect(propose).not.toHaveBeenCalled(); }); describe('a corrected measurement with an open suggestion', () => { beforeEach(() => { tx.suggestion.findFirst.mockResolvedValue({ id: 'open-1' }); }); it('looks for the open suggestion of this measurement by its dedupe key', async () => { await service.evaluateVarroaMeasurementIn(asTx(), measurement(12)); expect(tx.suggestion.findFirst).toHaveBeenCalledWith({ where: { ruleKey: 'VARROA_THRESHOLD', status: 'OPEN', dedupeKey: 'VARROA_THRESHOLD:colony-1:measurement-1', }, select: { id: true }, }); }); it('refreshes it while the corrected value is still red, and proposes nothing new', async () => { await service.evaluateVarroaMeasurementIn(asTx(), measurement(25)); expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'open-1', { reasonCode: 'VARROA_RED', evidence: expect.objectContaining({ measurementId: 'measurement-1', value: 25, }) as unknown, dueDate: null, priority: 'HIGH', expiresAt: null, }); expect(propose).not.toHaveBeenCalled(); expect(suggestions.resolveIn).not.toHaveBeenCalled(); }); it('resolves it once the corrected value is no longer red', async () => { await service.evaluateVarroaMeasurementIn(asTx(), measurement(10)); expect(suggestions.resolveIn).toHaveBeenCalledWith( tx, ['open-1'], 'Bedingung entfällt', ); expect(suggestions.refreshIn).not.toHaveBeenCalled(); expect(propose).not.toHaveBeenCalled(); }); it('resolves it when the corrected measurement has no value any more', async () => { await service.evaluateVarroaMeasurementIn(asTx(), measurement(null)); expect(suggestions.resolveIn).toHaveBeenCalledTimes(1); }); }); }); describe('evaluateSwarmControlCheckIn', () => { let propose: jest.SpyInstance; const swarmRule: Rule = { id: 'rule-4', triggerType: 'SWARM_MOOD_HIGH', active: true, config: {}, actionTemplate: 'Schwarmkontrolle dringend', }; const check = (swarmMood: string) => ({ id: 'check-1', colonyId: 'colony-1', date: new Date('2026-05-05T00:00:00.000Z'), swarmMood, }) as never; beforeEach(() => { propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); withRules(swarmRule); }); it('proposes a swarm check anchored on the check when the swarm mood is HIGH', async () => { await service.evaluateSwarmControlCheckIn(asTx(), check('HIGH')); expect(propose).toHaveBeenCalledWith(tx, 'SWARM_MOOD_HIGH', { relatedType: 'COLONY', relatedId: 'colony-1', colonyId: 'colony-1', intent: 'SWARM_CHECK', reasonCode: 'SWARM_MOOD_HIGH', evidence: { colonyId: 'colony-1', checkId: 'check-1', checkedOn: '2026-05-05', }, anchor: 'check-1', requiredPermission: 'swarm-control:read', dueDate: null, }); }); it('does not trigger for a low swarm mood or an inactive rule', async () => { await service.evaluateSwarmControlCheckIn(asTx(), check('LOW')); withRules({ ...swarmRule, active: false }); await service.evaluateSwarmControlCheckIn(asTx(), check('HIGH')); expect(propose).not.toHaveBeenCalled(); }); }); describe('evaluateColonyHealthIn', () => { let propose: jest.SpyInstance; const healthRule: Rule = { id: 'rule-5', triggerType: 'COLONY_HEALTH_CRITICAL', active: true, config: {}, actionTemplate: 'Kontrolle erforderlich', }; const state = (healthStatus: string | null, status = 'ACTIVE') => ({ status, healthStatus }) as never; const event = { id: 'event-1', occurredAt: new Date('2026-10-13T08:00:00.000Z'), }; beforeEach(() => { propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); withRules(healthRule); }); it.each([ ['AVERAGE', 'CRITICAL', 1], [null, 'CRITICAL', 1], ['CRITICAL', 'CRITICAL', 0], ['CRITICAL', 'GOOD', 0], ['GOOD', 'AVERAGE', 0], ['GOOD', null, 0], ])( 'colony-health fires only on the transition into CRITICAL (%s -> %s: %i suggestion)', async (from, to, expected) => { await service.evaluateColonyHealthIn( asTx(), 'colony-1', state(from), state(to), event, ); expect(propose).toHaveBeenCalledTimes(expected); }, ); // ColonyStatusChanged fires on every status or health change: without // the transition the rule must cost nothing - no read at all. it.each([ ['CRITICAL', 'CRITICAL'], ['GOOD', 'AVERAGE'], ['CRITICAL', null], ])( 'writes and reads nothing without the transition (%s -> %s)', async (from, to) => { await service.evaluateColonyHealthIn( asTx(), 'colony-1', state(from), state(to), event, ); expect(tx.automationRule.createMany).not.toHaveBeenCalled(); expect(tx.automationRule.findUnique).not.toHaveBeenCalled(); expect(tx.$queryRaw).not.toHaveBeenCalled(); }, ); it('does not re-trigger when only the status changes while CRITICAL', async () => { await service.evaluateColonyHealthIn( asTx(), 'colony-1', state('CRITICAL', 'ACTIVE'), state('CRITICAL', 'QUEENLESS'), event, ); expect(propose).not.toHaveBeenCalled(); }); it('hangs the suggestion on the colony and anchors it on the event', async () => { await service.evaluateColonyHealthIn( asTx(), 'colony-1', state('GOOD'), state('CRITICAL'), event, ); expect(propose).toHaveBeenCalledWith(tx, 'COLONY_HEALTH_CRITICAL', { relatedType: 'COLONY', relatedId: 'colony-1', colonyId: 'colony-1', intent: 'HEALTH_CHECK', reasonCode: 'COLONY_HEALTH_CRITICAL', evidence: { colonyId: 'colony-1', changedOn: '2026-10-13' }, anchor: 'event-1', requiredPermission: 'colonies:read', dueDate: null, sourceEventId: 'event-1', }); }); it('does not trigger an inactive rule', async () => { withRules({ ...healthRule, active: false }); await service.evaluateColonyHealthIn( asTx(), 'colony-1', state('GOOD'), state('CRITICAL'), event, ); expect(propose).not.toHaveBeenCalled(); }); }); describe('evaluateStockItemIn', () => { let propose: jest.SpyInstance; const stockRule: Rule = { id: 'rule-6', triggerType: 'STOCK_ITEM_LOW', active: true, config: {}, actionTemplate: 'Nachbestellung prüfen', }; beforeEach(() => { propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); withRules(stockRule); }); it('the stock rule anchors on the newest IN movement', async () => { tx.stockMovement.findFirst.mockResolvedValue({ id: 'movement-9' }); await service.evaluateStockItemIn(asTx(), { id: 'item-1', quantity: 2, minThreshold: 5, } as never); expect(tx.stockMovement.findFirst).toHaveBeenCalledWith({ where: { stockItemId: 'item-1', type: 'IN' }, orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], select: { id: true }, }); expect(propose).toHaveBeenCalledWith(tx, 'STOCK_ITEM_LOW', { relatedType: 'STORAGE', relatedId: 'item-1', colonyId: null, intent: 'STOCK_REORDER', reasonCode: 'STOCK_ITEM_LOW', evidence: { stockItemId: 'item-1', quantity: 2, minThreshold: 5 }, anchor: 'movement-9', requiredPermission: 'storage:read', dueDate: null, }); }); it('dates the reorder from today when the rule has dueInDays', async () => { jest.useFakeTimers({ now: new Date('2026-10-14T10:00:00.000Z') }); try { withRules({ ...stockRule, config: { dueInDays: 2 } }); await service.evaluateStockItemIn(asTx(), { id: 'item-1', quantity: 2, minThreshold: 5, } as never); expect(propose).toHaveBeenCalledWith( tx, 'STOCK_ITEM_LOW', expect.objectContaining({ dueDate: '2026-10-16' }), ); } finally { jest.useRealTimers(); } }); it('anchors on "none" for an item that was never restocked', async () => { await service.evaluateStockItemIn(asTx(), { id: 'item-1', quantity: 2, minThreshold: 5, } as never); expect(propose).toHaveBeenCalledWith( tx, 'STOCK_ITEM_LOW', expect.objectContaining({ anchor: 'none' }), ); }); it('does not trigger an inactive rule', async () => { withRules({ ...stockRule, active: false }); await service.evaluateStockItemIn(asTx(), { id: 'item-1', quantity: 2, minThreshold: 5, } as never); expect(propose).not.toHaveBeenCalled(); }); // Every stock movement evaluates its item: while it is not below its // minimum the rule must cost nothing - no seeding write, no read. it.each([ ['without a minimum', 2, null], ['at the minimum', 5, 5], ['above the minimum', 9, 5], ])( 'writes and reads nothing %s', async (_label, quantity, minThreshold) => { await service.evaluateStockItemIn(asTx(), { id: 'item-3', quantity, minThreshold, } as never); expect(tx.automationRule.createMany).not.toHaveBeenCalled(); expect(tx.automationRule.findUnique).not.toHaveBeenCalled(); expect(tx.stockMovement.findFirst).not.toHaveBeenCalled(); expect(propose).not.toHaveBeenCalled(); }, ); }); describe('evaluateRawMaterialLowIn', () => { let propose: jest.SpyInstance; const rawRule: Rule = { id: 'rule-7', triggerType: 'RAW_MATERIAL_LOW', active: true, config: { minGrams: { BEESWAX: 1000 } }, actionTemplate: 'Rohstoff unter Mindestmenge', }; const evaluate = (kind: RawMaterialKind, grams: number) => service.evaluateRawMaterialLowIn(asTx(), kind, grams); beforeEach(() => { propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); withRules(rawRule); }); it('proposes one reorder per kind, anchored on the newest lot of the kind', async () => { tx.rawMaterialLot.findFirst.mockResolvedValue({ id: 'lot-3' }); await evaluate('BEESWAX', 400); expect(tx.rawMaterialLot.findFirst).toHaveBeenCalledWith({ where: { kind: 'BEESWAX' }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], select: { id: true }, }); expect(propose).toHaveBeenCalledWith(tx, 'RAW_MATERIAL_LOW', { relatedType: 'GENERAL', relatedId: 'BEESWAX', colonyId: null, intent: 'STOCK_REORDER', reasonCode: 'RAW_MATERIAL_LOW', evidence: { kind: 'BEESWAX', remainingGrams: 400, minGrams: 1000 }, anchor: 'lot-3', requiredPermission: 'raw-material:read', dueDate: null, }); }); it('dates the reorder from today when the rule has dueInDays', async () => { jest.useFakeTimers({ now: new Date('2026-10-14T10:00:00.000Z') }); try { withRules({ ...rawRule, config: { ...(rawRule.config as object), dueInDays: 3 }, }); await evaluate('BEESWAX', 0); expect(propose).toHaveBeenCalledWith( tx, 'RAW_MATERIAL_LOW', expect.objectContaining({ dueDate: '2026-10-17' }), ); } finally { jest.useRealTimers(); } }); it('anchors on "none" while the kind has no lot', async () => { await evaluate('BEESWAX', 0); expect(propose).toHaveBeenCalledWith( tx, 'RAW_MATERIAL_LOW', expect.objectContaining({ anchor: 'none' }), ); }); it('does not trigger at or above the minimum', async () => { await evaluate('BEESWAX', 1000); expect(propose).not.toHaveBeenCalled(); }); it('does not trigger for a kind without a minimum', async () => { await evaluate('PROPOLIS', 0); expect(propose).not.toHaveBeenCalled(); }); it('does not trigger when the rule is inactive', async () => { withRules({ ...rawRule, active: false }); await evaluate('BEESWAX', 0); expect(propose).not.toHaveBeenCalled(); }); it('is off by default: without a rule row nothing is proposed', async () => { rules.clear(); await evaluate('BEESWAX', 0); expect(propose).not.toHaveBeenCalled(); }); }); describe('follow-up checks', () => { let propose: jest.SpyInstance; const followUpRule = (triggerType: string): Rule => ({ id: 'rule-9', triggerType, active: true, config: { dueInDays: 14 }, actionTemplate: 'Kontrollieren', }); beforeEach(() => { propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); }); it('evaluateTreatmentIn dates the check from the end of the treatment', async () => { withRules(followUpRule('TREATMENT_FOLLOW_UP')); await service.evaluateTreatmentIn(asTx(), { id: 'treatment-1', colonyId: 'colony-1', date: new Date('2026-08-01T00:00:00.000Z'), endDate: new Date('2026-08-10T00:00:00.000Z'), product: 'Ameisensäure', } as never); expect(propose).toHaveBeenCalledWith(tx, 'TREATMENT_FOLLOW_UP', { relatedType: 'COLONY', relatedId: 'colony-1', colonyId: 'colony-1', intent: 'FOLLOW_UP', reasonCode: 'TREATMENT_FOLLOW_UP', evidence: { colonyId: 'colony-1', treatmentId: 'treatment-1', endedOn: '2026-08-10', }, anchor: 'treatment-1', requiredPermission: 'treatments:read', dueDate: '2026-08-24', }); }); it('evaluateTreatmentIn counts from the treatment day without an end date', async () => { withRules(followUpRule('TREATMENT_FOLLOW_UP')); await service.evaluateTreatmentIn(asTx(), { id: 'treatment-1', colonyId: 'colony-1', date: new Date('2026-08-01T00:00:00.000Z'), endDate: null, } as never); expect(propose).toHaveBeenCalledWith( tx, 'TREATMENT_FOLLOW_UP', expect.objectContaining({ dueDate: '2026-08-15' }), ); }); it('evaluateFeedingIn dates the check from the feeding day', async () => { withRules(followUpRule('FEEDING_FOLLOW_UP')); await service.evaluateFeedingIn(asTx(), { id: 'feeding-1', colonyId: 'colony-1', date: new Date('2026-09-01T00:00:00.000Z'), amount: 5, unit: 'l', } as never); expect(propose).toHaveBeenCalledWith(tx, 'FEEDING_FOLLOW_UP', { relatedType: 'COLONY', relatedId: 'colony-1', colonyId: 'colony-1', intent: 'FOLLOW_UP', reasonCode: 'FEEDING_FOLLOW_UP', evidence: { colonyId: 'colony-1', feedingId: 'feeding-1', fedOn: '2026-09-01', }, anchor: 'feeding-1', requiredPermission: 'feedings:read', dueDate: '2026-09-15', }); }); it('proposes no follow-up while the rule is off (the default)', async () => { withRules({ ...followUpRule('FEEDING_FOLLOW_UP'), active: false }); const feeding = { id: 'feeding-1', colonyId: 'colony-1', date: new Date('2026-09-01T00:00:00.000Z'), amount: 5, unit: 'l', } as never; await service.evaluateFeedingIn(asTx(), feeding); rules.clear(); await service.evaluateFeedingIn(asTx(), feeding); expect(propose).not.toHaveBeenCalled(); }); const rootClient = () => prisma as unknown as Prisma.TransactionClient; it.each([ [ 'evaluateTreatmentIn', () => service.evaluateTreatmentIn(rootClient(), { id: 'treatment-1', colonyId: 'colony-1', date: new Date('2026-08-01T00:00:00.000Z'), endDate: null, product: 'Ameisensäure', } as never), ], [ 'evaluateFeedingIn', () => service.evaluateFeedingIn(rootClient(), { id: 'feeding-1', colonyId: 'colony-1', date: new Date('2026-09-01T00:00:00.000Z'), amount: 5, unit: 'l', } as never), ], ])( '%s names itself when it gets the root client', async (entryPoint, evaluate) => { await expect(evaluate()).rejects.toThrow( `AutomationService.${entryPoint} needs the transaction client`, ); expect(prisma.automationRule.createMany).not.toHaveBeenCalled(); }, ); }); describe('update', () => { // The rule row is locked first, then read, so "before" is the committed // state at the moment of the write. beforeEach(() => { tx.$queryRaw.mockResolvedValue([{ locked: 1 }]); }); it('locks the rule row and reads before inside the transaction', async () => { const calls: string[] = []; tx.$queryRaw.mockImplementation(() => { calls.push('lock'); return Promise.resolve([{ locked: 1 }]); }); tx.automationRule.findUnique.mockImplementation(() => { calls.push('read'); return Promise.resolve(varroaRule); }); tx.automationRule.update.mockImplementation(() => { calls.push('write'); return Promise.resolve({ ...varroaRule, active: false }); }); auditLog.record.mockImplementation(() => { calls.push('audit'); return Promise.resolve(); }); await service.update('rule-3', { active: false }, 'user-1'); expect(calls).toEqual(['lock', 'read', 'write', 'audit']); const [[lockQuery]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]]; expect(lockQuery.sql).toContain('FROM "AutomationRule"'); expect(lockQuery.sql).toContain('FOR UPDATE'); expect(lockQuery.values).toEqual(['rule-3']); // Nothing is read or written outside the transaction. expect(prisma.automationRule.findUnique).not.toHaveBeenCalled(); expect(prisma.automationRule.update).not.toHaveBeenCalled(); }); it('answers 404 when the rule does not exist', async () => { tx.$queryRaw.mockResolvedValue([]); await expect( service.update('missing', { active: false }, 'user-1'), ).rejects.toBeInstanceOf(NotFoundException); expect(tx.automationRule.update).not.toHaveBeenCalled(); }); it('records an audit log entry with before/after data in the transaction', async () => { tx.automationRule.findUnique.mockResolvedValue(varroaRule); tx.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', beforeData: varroaRule, }), tx, ); }); it('clears the config on null so the rule falls back to its defaults', async () => { tx.automationRule.findUnique.mockResolvedValue(varroaRule); tx.automationRule.update.mockResolvedValue({ ...varroaRule, config: null, }); await service.update('rule-3', { config: null as never }, 'user-1'); const [[updateArg]] = tx.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 () => { tx.automationRule.findUnique.mockResolvedValue(varroaRule); tx.automationRule.update.mockResolvedValue(varroaRule); await service.update('rule-3', { active: true }, 'user-1'); const [[updateArg]] = tx.automationRule.update.mock.calls as [ [{ data: { config?: unknown } }], ]; expect(updateArg.data.config).toBeUndefined(); }); it('rejects an invalid known value with the field and writes nothing', async () => { tx.automationRule.findUnique.mockResolvedValue({ ...varroaRule, id: 'rule-9', triggerType: 'SEASON_AUTOPILOT', config: {}, }); const result = service.update( 'rule-9', { config: { queenMaxAgeYears: 0, somethingElse: true } }, 'user-1', ); await expect(result).rejects.toBeInstanceOf(BadRequestException); await expect(result).rejects.toMatchObject({ response: { message: 'Die Einstellung ist ungültig.', issues: [{ field: 'queenMaxAgeYears' }], }, }); expect(tx.automationRule.update).not.toHaveBeenCalled(); expect(auditLog.record).not.toHaveBeenCalled(); }); it('keeps unknown keys of a valid config as sent', async () => { tx.automationRule.findUnique.mockResolvedValue(varroaRule); tx.automationRule.update.mockResolvedValue(varroaRule); await service.update( 'rule-3', { config: { thresholdMitesPerDay: 7, somethingElse: true } }, 'user-1', ); const [[updateArg]] = tx.automationRule.update.mock.calls as [ [{ data: { config?: unknown } }], ]; expect(updateArg.data.config).toEqual({ thresholdMitesPerDay: 7, somethingElse: true, }); }); }); 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', }), ); }); }); });