- 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]>
218 lines
6.7 KiB
TypeScript
218 lines
6.7 KiB
TypeScript
import { Prisma } from '../generated/prisma';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import {
|
|
lockRowForUpdate,
|
|
lockRowsForShare,
|
|
lockRowsForUpdate,
|
|
} from '../src/prisma/row-lock';
|
|
import {
|
|
DEFAULT_RULES,
|
|
lockRuleIn,
|
|
readRule,
|
|
} from '../src/automation/rule-config';
|
|
|
|
/**
|
|
* Schema-level guarantees of the T4 suggestion migrations that the Prisma
|
|
* schema alone cannot express (nullable unique dedupe key, enum values the
|
|
* database really accepts) and the row locks against a real PostgreSQL.
|
|
*/
|
|
describe('T4 schema I (e2e)', () => {
|
|
let prisma: PrismaService;
|
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
|
const createdSuggestionIds: string[] = [];
|
|
const createdTaskIds: string[] = [];
|
|
|
|
const suggestionData = (
|
|
overrides: Partial<Prisma.SuggestionCreateManyInput> = {},
|
|
): Prisma.SuggestionCreateManyInput => ({
|
|
ruleKey: 'INSPECTION_INTERVAL',
|
|
source: 'RULE',
|
|
mode: 'DO',
|
|
intent: 'INSPECT',
|
|
relatedType: 'COLONY',
|
|
reasonCode: 'T4_SCHEMA_TEST',
|
|
evidence: { colonies: 1 },
|
|
...overrides,
|
|
});
|
|
|
|
beforeAll(() => {
|
|
prisma = new PrismaService();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.task.deleteMany({ where: { id: { in: createdTaskIds } } });
|
|
await prisma.suggestion.deleteMany({
|
|
where: {
|
|
OR: [
|
|
{ id: { in: createdSuggestionIds } },
|
|
{ reasonCode: `T4_SCHEMA_TEST_${suffix}` },
|
|
],
|
|
},
|
|
});
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
it('keeps at most one suggestion per dedupe key', async () => {
|
|
const data = suggestionData({
|
|
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
|
|
dedupeKey: `INSPECTION_INTERVAL:none:t-${suffix}`,
|
|
});
|
|
|
|
const first = await prisma.suggestion.createMany({
|
|
data: [data],
|
|
skipDuplicates: true,
|
|
});
|
|
const second = await prisma.suggestion.createMany({
|
|
data: [data],
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
expect(first.count).toBe(1);
|
|
expect(second.count).toBe(0);
|
|
});
|
|
|
|
it('lets any number of suggestions share a missing dedupe key', async () => {
|
|
const reasonCode = `T4_SCHEMA_TEST_${suffix}`;
|
|
const result = await prisma.suggestion.createMany({
|
|
data: [
|
|
suggestionData({ reasonCode, dedupeKey: null }),
|
|
suggestionData({ reasonCode, dedupeKey: null }),
|
|
],
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
expect(result.count).toBe(2);
|
|
});
|
|
|
|
it('starts a suggestion OPEN with normal priority', async () => {
|
|
const created = await prisma.suggestion.create({
|
|
data: suggestionData({
|
|
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
|
|
}),
|
|
});
|
|
createdSuggestionIds.push(created.id);
|
|
|
|
expect(created.status).toBe('OPEN');
|
|
expect(created.priority).toBe('NORMAL');
|
|
expect(created.dedupeKey).toBeNull();
|
|
});
|
|
|
|
it('keeps a task when its source suggestion is deleted', async () => {
|
|
const suggestion = await prisma.suggestion.create({
|
|
data: suggestionData({
|
|
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
|
|
}),
|
|
});
|
|
const task = await prisma.task.create({
|
|
data: { title: `T4 task ${suffix}`, sourceSuggestionId: suggestion.id },
|
|
});
|
|
createdTaskIds.push(task.id);
|
|
|
|
await prisma.suggestion.delete({ where: { id: suggestion.id } });
|
|
|
|
const after = await prisma.task.findUniqueOrThrow({
|
|
where: { id: task.id },
|
|
});
|
|
expect(after.sourceSuggestionId).toBeNull();
|
|
});
|
|
|
|
it('accepts the new enum values', async () => {
|
|
const rows = await prisma.$queryRaw<
|
|
{ enum: string; values: string[] }[]
|
|
>`SELECT t.typname AS enum, array_agg(e.enumlabel::text) AS values
|
|
FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid
|
|
WHERE t.typname IN ('AutomationTriggerType', 'BatchMovementType', 'CalendarSyncSourceType')
|
|
GROUP BY t.typname`;
|
|
const valuesOf = (name: string) =>
|
|
rows.find((row) => row.enum === name)?.values ?? [];
|
|
|
|
expect(valuesOf('AutomationTriggerType')).toEqual(
|
|
expect.arrayContaining([
|
|
'INSPECTION_INTERVAL',
|
|
'QUEEN_CELLS_SWARM_CHECK',
|
|
'QUEEN_ACCEPTANCE_CHECK',
|
|
'SPLIT_SCHEDULE',
|
|
'COLONY_ENDED_CLEANUP',
|
|
'WITHDRAWAL_ENDED',
|
|
'HONEY_WATER_CONTENT',
|
|
'SEASON_AUTOPILOT',
|
|
'PHENOLOGY',
|
|
]),
|
|
);
|
|
expect(valuesOf('BatchMovementType')).toContain('BOTTLING');
|
|
expect(valuesOf('CalendarSyncSourceType')).toContain('WITHDRAWAL');
|
|
});
|
|
|
|
describe('row locks on the new tables', () => {
|
|
it('locks a suggestion by id and reports a missing one', async () => {
|
|
const suggestion = await prisma.suggestion.create({
|
|
data: suggestionData({
|
|
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
|
|
}),
|
|
});
|
|
createdSuggestionIds.push(suggestion.id);
|
|
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
const existing = await lockRowForUpdate(
|
|
tx,
|
|
'Suggestion',
|
|
suggestion.id,
|
|
);
|
|
await lockRowsForUpdate(tx, 'Suggestion', [suggestion.id, 'nope']);
|
|
await lockRowsForShare(tx, 'Suggestion', [suggestion.id]);
|
|
const missing = await lockRowForUpdate(tx, 'Suggestion', 'nope');
|
|
return { existing, missing };
|
|
});
|
|
|
|
expect(result).toEqual({ existing: true, missing: false });
|
|
});
|
|
|
|
it('creates a missing rule from the defaults, locks it and reads it parsed', async () => {
|
|
await prisma.automationRule.deleteMany({
|
|
where: { triggerType: 'COLONY_ENDED_CLEANUP' },
|
|
});
|
|
const before = await readRule(prisma, 'COLONY_ENDED_CLEANUP');
|
|
expect(before.id).toBeNull();
|
|
expect(before.active).toBe(true);
|
|
|
|
const locked = await prisma.$transaction((tx) =>
|
|
lockRuleIn(tx, 'COLONY_ENDED_CLEANUP'),
|
|
);
|
|
|
|
expect(locked.active).toBe(true);
|
|
expect(locked.config).toEqual({});
|
|
expect(locked.actionTemplate).toBe(
|
|
DEFAULT_RULES.COLONY_ENDED_CLEANUP.actionTemplate,
|
|
);
|
|
const after = await readRule(prisma, 'COLONY_ENDED_CLEANUP');
|
|
expect(after.id).toBe(locked.id);
|
|
});
|
|
|
|
it('leaves an existing rule as it is', async () => {
|
|
const rule = await prisma.automationRule.upsert({
|
|
where: { triggerType: 'WITHDRAWAL_ENDED' },
|
|
create: {
|
|
triggerType: 'WITHDRAWAL_ENDED',
|
|
active: false,
|
|
config: { notify: false },
|
|
actionTemplate: '',
|
|
},
|
|
update: { active: false, config: { notify: false } },
|
|
});
|
|
|
|
const locked = await prisma.$transaction((tx) =>
|
|
lockRuleIn(tx, 'WITHDRAWAL_ENDED'),
|
|
);
|
|
|
|
expect(locked.id).toBe(rule.id);
|
|
expect(locked.active).toBe(false);
|
|
expect(locked.config).toEqual({ notify: false });
|
|
|
|
await prisma.automationRule.update({
|
|
where: { id: rule.id },
|
|
data: { active: true, config: { notify: true } },
|
|
});
|
|
});
|
|
});
|
|
});
|