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 => ({ 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 } }, }); }); }); });