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
@@ -0,0 +1,217 @@
|
||||
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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user