feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- Rule configs with typed parsers and registry; Berlin day helpers - Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as ColonyCondition with legacy text mapping), split queen source, varroa sample size; migration harness for data migrations - SuggestionsService: propose/refresh/resolve/obsolete/expire, accept/dismiss/reopen incl. bulk, lock order, concurrency e2e - SeasonService: phenology-based segments, inspection/varroa due dates, queen age; TimeEventsStep with JobPartialFailure - Frontend: per-rule settings forms with visible value sources Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
7097fcd97c
commit
d99a697739
116 files changed
+11239
-801
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { Logger, NotFoundException } from '@nestjs/common';
|
||||
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';
|
||||
@@ -26,6 +26,7 @@ describe('AutomationService', () => {
|
||||
createMany: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
task: { findFirst: jest.Mock };
|
||||
$queryRaw: jest.Mock;
|
||||
@@ -84,6 +85,7 @@ describe('AutomationService', () => {
|
||||
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
},
|
||||
task: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
@@ -438,7 +440,7 @@ describe('AutomationService', () => {
|
||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers when feed status is kritisch, case-insensitive', async () => {
|
||||
it('triggers when feed status is CRITICAL', async () => {
|
||||
tx.automationRule.findMany.mockResolvedValue([feedRule]);
|
||||
|
||||
await service.evaluateInspectionIn(asTx(), {
|
||||
@@ -446,7 +448,7 @@ describe('AutomationService', () => {
|
||||
colonyId: 'colony-1',
|
||||
queenSeen: true,
|
||||
eggsPresent: true,
|
||||
feedStatus: 'Kritisch',
|
||||
feedStatus: 'CRITICAL',
|
||||
} as never);
|
||||
|
||||
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
|
||||
@@ -461,7 +463,7 @@ describe('AutomationService', () => {
|
||||
|
||||
await service.evaluateInspectionIn(asTx(), {
|
||||
...queenlessInspection,
|
||||
feedStatus: 'kritisch',
|
||||
feedStatus: 'CRITICAL',
|
||||
} as never);
|
||||
|
||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||
@@ -917,9 +919,55 @@ describe('AutomationService', () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('records an audit log entry with before/after data', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue({
|
||||
// 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,
|
||||
});
|
||||
@@ -931,20 +979,22 @@ describe('AutomationService', () => {
|
||||
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 () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue({
|
||||
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]] = prisma.automationRule.update.mock.calls as [
|
||||
const [[updateArg]] = tx.automationRule.update.mock.calls as [
|
||||
[{ data: { config?: unknown } }],
|
||||
];
|
||||
// A Json column is cleared with DbNull - a plain null fails the write.
|
||||
@@ -952,16 +1002,60 @@ describe('AutomationService', () => {
|
||||
});
|
||||
|
||||
it('leaves the config untouched when it is omitted', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue(varroaRule);
|
||||
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
tx.automationRule.update.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.update('rule-3', { active: true }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
|
||||
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', () => {
|
||||
|
||||
Reference in new issue
Block a user