- 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]>
1858 lines
58 KiB
TypeScript
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',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|