Files
ApiCore/ApiCore-Backend/src/automation/automation.service.spec.ts
T
Nick SzaunigandClaude Opus 5.5 ef33e5549b feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 16:28:07 +02:00

1858 lines
58 KiB
TypeScript

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<string, Rule>;
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<void>,
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<unknown>) => {
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<string, unknown> = {}) =>
({
...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<string, unknown> = {},
) =>
({
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',
}),
);
});
});
});