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
@@ -7,6 +7,7 @@ import { AutomationTriggerType, Prisma } from '../generated/prisma';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
import { drainDomainEvents } from './utils/domain-events';
|
||||
import { DEFAULT_RULES } from '../src/automation/rule-config';
|
||||
|
||||
interface AutomationRuleBody {
|
||||
id: string;
|
||||
@@ -165,6 +166,94 @@ describe('Automation rules (e2e)', () => {
|
||||
)!.id;
|
||||
});
|
||||
|
||||
const NEW_TRIGGERS = [
|
||||
'INSPECTION_INTERVAL',
|
||||
'QUEEN_CELLS_SWARM_CHECK',
|
||||
'QUEEN_ACCEPTANCE_CHECK',
|
||||
'SPLIT_SCHEDULE',
|
||||
'COLONY_ENDED_CLEANUP',
|
||||
'WITHDRAWAL_ENDED',
|
||||
'HONEY_WATER_CONTENT',
|
||||
'SEASON_AUTOPILOT',
|
||||
'PHENOLOGY',
|
||||
] as const;
|
||||
|
||||
it('lists the nine new rules with their defaults', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/automation-rules')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const rules = response.body as AutomationRuleBody[];
|
||||
|
||||
for (const trigger of NEW_TRIGGERS) {
|
||||
const rule = rules.find((entry) => entry.triggerType === trigger);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule!.active).toBe(DEFAULT_RULES[trigger].active ?? true);
|
||||
expect(rule!.config).toEqual(DEFAULT_RULES[trigger].config);
|
||||
expect(rule!.actionTemplate).toBe('');
|
||||
}
|
||||
// Existing rules keep their stored config; a fresh database has the new defaults.
|
||||
const varroa = rules.find(
|
||||
(rule) => rule.triggerType === 'VARROA_THRESHOLD',
|
||||
);
|
||||
expect(varroa?.config).toBeTruthy();
|
||||
});
|
||||
|
||||
it('PATCH a rule with an invalid known value answers 400 with the field', async () => {
|
||||
const rules = (
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/automation-rules')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200)
|
||||
).body as AutomationRuleBody[];
|
||||
const autopilot = rules.find(
|
||||
(rule) => rule.triggerType === 'SEASON_AUTOPILOT',
|
||||
)!;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch(`/api/v1/automation-rules/${autopilot.id}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ config: { queenMaxAgeYears: 0 } })
|
||||
.expect(400);
|
||||
|
||||
const body = res.body as {
|
||||
message: string;
|
||||
issues: { field: string; message: string }[];
|
||||
};
|
||||
expect(body.message).toBe('Die Einstellung ist ungültig.');
|
||||
expect(body.issues).toEqual([
|
||||
expect.objectContaining({ field: 'queenMaxAgeYears' }),
|
||||
]);
|
||||
// Nothing was written.
|
||||
const stored = await prisma.automationRule.findUniqueOrThrow({
|
||||
where: { id: autopilot.id },
|
||||
});
|
||||
expect(stored.config).toEqual(autopilot.config);
|
||||
});
|
||||
|
||||
it('PATCH keeps unknown keys and saves a valid config', async () => {
|
||||
const rule = await prisma.automationRule.findUniqueOrThrow({
|
||||
where: { triggerType: 'SEASON_AUTOPILOT' },
|
||||
});
|
||||
try {
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/automation-rules/${rule.id}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ config: { queenMaxAgeYears: 3, futureKey: 'x' } })
|
||||
.expect(200);
|
||||
|
||||
const stored = await prisma.automationRule.findUniqueOrThrow({
|
||||
where: { id: rule.id },
|
||||
});
|
||||
expect(stored.config).toEqual({ queenMaxAgeYears: 3, futureKey: 'x' });
|
||||
} finally {
|
||||
await prisma.automationRule.update({
|
||||
where: { id: rule.id },
|
||||
data: { config: rule.config as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a task and notification when queen not seen and no eggs present', async () => {
|
||||
await record('inspections', { queenSeen: false, eggsPresent: false });
|
||||
|
||||
@@ -190,7 +279,7 @@ describe('Automation rules (e2e)', () => {
|
||||
});
|
||||
|
||||
it('triggers a feeding task when feed status is kritisch', async () => {
|
||||
await record('inspections', { feedStatus: 'kritisch' });
|
||||
await record('inspections', { feedStatus: 'CRITICAL' });
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { relatedId: colonyId },
|
||||
@@ -212,7 +301,7 @@ describe('Automation rules (e2e)', () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/inspections/${id}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ queenSeen: false, eggsPresent: false, feedStatus: 'kritisch' })
|
||||
.send({ queenSeen: false, eggsPresent: false, feedStatus: 'CRITICAL' })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [id]);
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { INestApplicationContext } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import { DailyEvaluationJob } from '../src/jobs/daily-evaluation.job';
|
||||
import { JobsModule } from '../src/jobs/jobs.module';
|
||||
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
|
||||
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
|
||||
|
||||
const NOTHING_NEW = {
|
||||
WithdrawalEnded: 0,
|
||||
@@ -21,14 +19,14 @@ const NOTHING_NEW = {
|
||||
* The job evaluates the whole database. The e2e files share it and run in
|
||||
* parallel, so the evaluation runs on days in 2001: no other file has
|
||||
* records there, and the events of this file stay its own. Called through
|
||||
* evaluate(day) instead of runCli, which would also write JobRun rows that
|
||||
* test/jobs.e2e-spec.ts counts.
|
||||
* TimeEventsStep.run(day) instead of runCli, which would also write JobRun
|
||||
* rows that test/jobs.e2e-spec.ts counts.
|
||||
*/
|
||||
describe('daily-evaluation (e2e)', () => {
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
let app: INestApplicationContext;
|
||||
let prisma: PrismaService;
|
||||
let job: DailyEvaluationJob;
|
||||
let step: TimeEventsStep;
|
||||
const ids: Record<string, string> = {};
|
||||
|
||||
const eventsOf = (aggregateId: string) =>
|
||||
@@ -50,7 +48,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
}).compile();
|
||||
app = await moduleRef.init();
|
||||
prisma = app.get(PrismaService);
|
||||
job = app.get(DailyEvaluationJob);
|
||||
step = app.get(TimeEventsStep);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -99,7 +97,8 @@ describe('daily-evaluation (e2e)', () => {
|
||||
},
|
||||
});
|
||||
ids.dead = dead.id;
|
||||
// Inspected 2001-04-28: the next inspection is overdue on 2001-05-19.
|
||||
// Inspected 2001-04-28, in the swarm season (every 7 days): the next
|
||||
// inspection is due 2001-05-05 and overdue on 2001-05-06.
|
||||
for (const colonyId of [living.id, dead.id]) {
|
||||
await prisma.inspection.create({
|
||||
data: { colonyId, userId: user.id, date: d('2001-04-28') },
|
||||
@@ -180,10 +179,10 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
|
||||
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => {
|
||||
const first = await job.evaluate(at('2001-05-20'));
|
||||
const second = await job.evaluate(at('2001-05-20'));
|
||||
const first = await step.run('2001-05-20');
|
||||
const second = await step.run('2001-05-20');
|
||||
// The next day the occurrence is still in the window - and still known.
|
||||
const nextDay = await job.evaluate(at('2001-05-21'));
|
||||
const nextDay = await step.run('2001-05-21');
|
||||
|
||||
expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1);
|
||||
expect(second).toEqual(NOTHING_NEW);
|
||||
@@ -201,8 +200,8 @@ describe('daily-evaluation (e2e)', () => {
|
||||
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('records due tasks and overdue checks only for open tasks and living colonies', async () => {
|
||||
await job.evaluate(at('2001-05-20'));
|
||||
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
|
||||
await step.run('2001-05-20');
|
||||
|
||||
expect(await eventsOf(ids['task:open'])).toEqual([
|
||||
{
|
||||
@@ -215,17 +214,15 @@ describe('daily-evaluation (e2e)', () => {
|
||||
]);
|
||||
expect(await eventsOf(ids['task:done'])).toEqual([]);
|
||||
expect(await eventsOf(ids.living)).toEqual([
|
||||
{
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
payload: { dueOn: '2001-05-19', lastInspectedOn: '2001-04-28' },
|
||||
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-19`,
|
||||
actorUserId: null,
|
||||
},
|
||||
{
|
||||
type: 'VarroaMeasurementDue',
|
||||
aggregateType: 'Colony',
|
||||
payload: { dueOn: '2001-05-18', lastMeasuredOn: null },
|
||||
payload: {
|
||||
dueOn: '2001-05-18',
|
||||
lastMeasuredOn: null,
|
||||
kind: 'INTERVAL',
|
||||
anchor: 'none',
|
||||
},
|
||||
dedupeKey: `VarroaMeasurementDue:${ids.living}:2001-05-18`,
|
||||
actorUserId: null,
|
||||
},
|
||||
@@ -233,11 +230,70 @@ describe('daily-evaluation (e2e)', () => {
|
||||
expect(await eventsOf(ids.dead)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports active queens from their second year in the first days of January', async () => {
|
||||
await job.evaluate(at('2001-01-05'));
|
||||
expect(await job.evaluate(at('2001-01-08'))).toEqual(NOTHING_NEW);
|
||||
it('InspectionOverdue follows the segment interval', async () => {
|
||||
await step.run('2001-05-12');
|
||||
|
||||
expect(await eventsOf(ids['queen:aged'])).toEqual([
|
||||
// SCHWARMZEIT since 2001-04-15: 2001-04-28 + 7 = 2001-05-05, not + 21.
|
||||
expect(
|
||||
(await eventsOf(ids.living)).filter(
|
||||
(event) => event.type === 'InspectionOverdue',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
payload: {
|
||||
dueOn: '2001-05-05',
|
||||
lastInspectedOn: '2001-04-28',
|
||||
firstOfYear: false,
|
||||
},
|
||||
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-06`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
expect(await eventsOf(ids.dead)).toEqual([]);
|
||||
});
|
||||
|
||||
it('records no inspection in WINTERRUHE and the first one the day after FRUEHJAHR begins', async () => {
|
||||
await step.run('2002-03-10');
|
||||
expect(
|
||||
(await eventsOf(ids.living)).filter((event) =>
|
||||
event.dedupeKey?.endsWith(':2002-03-21'),
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
// FRUEHJAHR begins 2002-03-20; the colony was last inspected in 2001.
|
||||
await step.run('2002-03-21');
|
||||
expect(
|
||||
(await eventsOf(ids.living)).filter((event) =>
|
||||
event.dedupeKey?.endsWith(':2002-03-21'),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
payload: {
|
||||
dueOn: '2002-03-20',
|
||||
lastInspectedOn: '2001-04-28',
|
||||
firstOfYear: true,
|
||||
},
|
||||
dedupeKey: `InspectionOverdue:${ids.living}:2002-03-21`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports the current queen of a living colony once she is aged, on any job day of the year', async () => {
|
||||
await step.run('2001-09-09');
|
||||
await step.run('2001-09-10');
|
||||
|
||||
// One per queen and year: the 2002 run of the test above added its own.
|
||||
const events = await eventsOf(ids['queen:aged']);
|
||||
expect(events.map((event) => event.dedupeKey)).toEqual([
|
||||
`QueenAged:${ids['queen:aged']}:2001-01-01`,
|
||||
`QueenAged:${ids['queen:aged']}:2002-01-01`,
|
||||
]);
|
||||
expect(events.slice(0, 1)).toEqual([
|
||||
{
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
|
||||
@@ -11,6 +11,7 @@ interface InspectionBody {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
broodFrames?: number | null;
|
||||
feedStatus?: string | null;
|
||||
}
|
||||
|
||||
interface TaskBody {
|
||||
@@ -268,4 +269,37 @@ describe('Inspections (e2e)', () => {
|
||||
.send({ broodFrames: 'x' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('maps legacy feed words to the enum, accepts enum names and rejects other text', async () => {
|
||||
const post = (feedStatus: unknown) =>
|
||||
request(app.getHttpServer())
|
||||
.post('/api/v1/inspections')
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ colonyId, date: new Date().toISOString(), feedStatus });
|
||||
|
||||
await post('halb voll').expect(400);
|
||||
await post(5).expect(400);
|
||||
|
||||
const blank = await post(' ').expect(201);
|
||||
expect((blank.body as InspectionBody).feedStatus).toBeNull();
|
||||
const legacy = await post(' Niedrig ').expect(201);
|
||||
expect((legacy.body as InspectionBody).feedStatus).toBe('WEAK');
|
||||
const created = await post('WEAK').expect(201);
|
||||
const body = created.body as InspectionBody;
|
||||
expect(body.feedStatus).toBe('WEAK');
|
||||
|
||||
const patch = (feedStatus: unknown) =>
|
||||
request(app.getHttpServer())
|
||||
.patch(`/api/v1/inspections/${body.id}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ feedStatus });
|
||||
await patch('irgendwas').expect(400);
|
||||
const mapped = await patch('Kritisch').expect(200);
|
||||
expect((mapped.body as InspectionBody).feedStatus).toBe('CRITICAL');
|
||||
const blankPatch = await patch(' ').expect(200);
|
||||
expect((blankPatch.body as InspectionBody).feedStatus).toBeNull();
|
||||
await patch('WEAK').expect(200);
|
||||
const cleared = await patch(null).expect(200);
|
||||
expect((cleared.body as InspectionBody).feedStatus).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { SuggestionsService } from '../src/suggestions/suggestions.service';
|
||||
import type { ProposeInput } from '../src/suggestions/suggestion.types';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
|
||||
/**
|
||||
* The suggestion core against a real PostgreSQL: the dedupe key, the SQL
|
||||
* status changes that free it, the plan tasks and the 30-day reopen window.
|
||||
*/
|
||||
describe('Suggestions (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
let service: SuggestionsService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
let userId: string;
|
||||
const proposal = (anchor: string): ProposeInput => ({
|
||||
ruleKey: 'SPLIT_SCHEDULE',
|
||||
source: 'CHAIN',
|
||||
mode: 'PLAN',
|
||||
intent: 'SPLIT_PLAN',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonyId,
|
||||
colonyId,
|
||||
reasonCode: `SUGGESTIONS_E2E_${suffix}`,
|
||||
evidence: { colonyId, baseDate: '2026-05-01' },
|
||||
requiredPermission: 'colonies:read',
|
||||
plan: [
|
||||
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
|
||||
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
|
||||
],
|
||||
anchor,
|
||||
});
|
||||
|
||||
const propose = (input: ProposeInput) =>
|
||||
prisma.$transaction((tx) => service.proposeIn(tx, input));
|
||||
|
||||
const statusOf = async (id: string) =>
|
||||
(await prisma.suggestion.findUniqueOrThrow({ where: { id } })).status;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
service = app.get(SuggestionsService);
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Suggestions e2e ${suffix}` },
|
||||
});
|
||||
apiaryId = apiary.id;
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `SUG-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyId = colony.id;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `suggestions-e2e-${suffix}@example.com`,
|
||||
passwordHash: 'x',
|
||||
name: 'Suggestions e2e',
|
||||
role: { connect: { key: 'STAFF' } },
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.task.deleteMany({
|
||||
where: { sourceSuggestion: { colonyId } },
|
||||
});
|
||||
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||
await prisma.colony.delete({ where: { id: colonyId } });
|
||||
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.task.deleteMany({
|
||||
where: { sourceSuggestion: { colonyId } },
|
||||
});
|
||||
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||
});
|
||||
|
||||
it('proposes once per anchor and writes an audit entry', async () => {
|
||||
const created = await propose(proposal('a1'));
|
||||
expect(created).not.toBeNull();
|
||||
expect(created?.dedupeKey).toBe(`SPLIT_SCHEDULE:${colonyId}:a1`);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { entityType: 'Suggestion', entityId: created!.id },
|
||||
}),
|
||||
).toBe(1);
|
||||
|
||||
// Same anchor: the key exists. A second intent check would also stop it.
|
||||
expect(await propose(proposal('a1'))).toBeNull();
|
||||
});
|
||||
|
||||
it('allows one open suggestion per intent and colony', async () => {
|
||||
expect(await propose(proposal('a1'))).not.toBeNull();
|
||||
expect(await propose(proposal('a2'))).toBeNull();
|
||||
expect(
|
||||
await propose({
|
||||
...proposal('a3'),
|
||||
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||
intent: 'QUEEN_ACCEPTANCE',
|
||||
plan: null,
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('is stopped by an open task of the same intent', async () => {
|
||||
const first = (await propose(proposal('a1')))!;
|
||||
await service.accept(first.id, { dueDate: '2026-05-12' }, userId);
|
||||
// The accepted plan left open tasks of this intent.
|
||||
expect(await propose(proposal('a2'))).toBeNull();
|
||||
});
|
||||
|
||||
it('expireDue frees the key, dismiss keeps it', async () => {
|
||||
const dismissed = (await propose(proposal('d1')))!;
|
||||
await service.dismiss(dismissed.id, 'nein', userId);
|
||||
expect(await statusOf(dismissed.id)).toBe('DISMISSED');
|
||||
expect(await propose(proposal('d1'))).toBeNull();
|
||||
|
||||
const expiring = (await propose({
|
||||
...proposal('e1'),
|
||||
expiresAt: '2026-05-01',
|
||||
}))!;
|
||||
expect(await service.expireDue('2026-05-02')).toBeGreaterThanOrEqual(1);
|
||||
const expired = await prisma.suggestion.findUniqueOrThrow({
|
||||
where: { id: expiring.id },
|
||||
});
|
||||
expect(expired.status).toBe('EXPIRED');
|
||||
expect(expired.dedupeKey).toBeNull();
|
||||
expect(await propose(proposal('e1'))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('resolveIn frees the key in the same statement', async () => {
|
||||
const created = (await propose(proposal('r1')))!;
|
||||
|
||||
expect(
|
||||
await prisma.$transaction((tx) =>
|
||||
service.resolveIn(tx, [created.id], 'Bedingung entfällt'),
|
||||
),
|
||||
).toBe(1);
|
||||
|
||||
const resolved = await prisma.suggestion.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(resolved).toMatchObject({
|
||||
status: 'RESOLVED',
|
||||
dedupeKey: null,
|
||||
decisionNote: 'Bedingung entfällt',
|
||||
});
|
||||
expect(await propose(proposal('r1'))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('accept creates the plan tasks shifted to the chosen day', async () => {
|
||||
const created = (await propose(proposal('p1')))!;
|
||||
|
||||
const result = await service.accept(
|
||||
created.id,
|
||||
{ dueDate: '2026-05-12' },
|
||||
userId,
|
||||
);
|
||||
|
||||
expect(
|
||||
result.tasks.map((t) => t.dueDate?.toISOString().slice(0, 10)),
|
||||
).toEqual(['2026-05-12', '2026-05-24']);
|
||||
expect(result.tasks[1].description).toContain('bis 27.05.');
|
||||
expect(result.tasks.every((t) => t.sourceSuggestionId === created.id)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await statusOf(created.id)).toBe('ACCEPTED');
|
||||
await expect(service.accept(created.id, {}, userId)).rejects.toMatchObject({
|
||||
response: { status: 'ACCEPTED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('bulkAccept rolls everything back when one suggestion is not open', async () => {
|
||||
const open = (await propose(proposal('b1')))!;
|
||||
const other = (await propose({
|
||||
...proposal('b2'),
|
||||
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||
intent: 'QUEEN_ACCEPTANCE',
|
||||
plan: null,
|
||||
dueDate: '2026-05-20',
|
||||
}))!;
|
||||
await service.dismiss(other.id, null, userId);
|
||||
|
||||
await expect(
|
||||
service.bulkAccept([open.id, other.id], userId),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
|
||||
expect(await statusOf(open.id)).toBe('OPEN');
|
||||
expect(
|
||||
await prisma.task.count({ where: { sourceSuggestionId: open.id } }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('reopens within 30 days and answers 409 after that', async () => {
|
||||
const created = (await propose(proposal('o1')))!;
|
||||
await service.dismiss(created.id, null, userId);
|
||||
|
||||
const reopened = await service.reopen(created.id, userId);
|
||||
expect(reopened).toMatchObject({ status: 'OPEN', decidedAt: null });
|
||||
|
||||
await service.dismiss(created.id, null, userId);
|
||||
await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() - interval '31 days' WHERE id = ${created.id}`;
|
||||
await expect(service.reopen(created.id, userId)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
expect(await statusOf(created.id)).toBe('DISMISSED');
|
||||
});
|
||||
|
||||
it('refuses to reopen while the same intent is open again', async () => {
|
||||
const dismissed = (await propose(proposal('q1')))!;
|
||||
await service.dismiss(dismissed.id, null, userId);
|
||||
expect(await propose(proposal('q2'))).not.toBeNull();
|
||||
|
||||
await expect(service.reopen(dismissed.id, userId)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
expect(await statusOf(dismissed.id)).toBe('DISMISSED');
|
||||
});
|
||||
|
||||
it('answers 400 for an unknown or inactive assignee, never 500', async () => {
|
||||
const created = (await propose(proposal('u1')))!;
|
||||
const inactive = await prisma.user.create({
|
||||
data: {
|
||||
email: `suggestions-e2e-inactive-${suffix}@example.com`,
|
||||
passwordHash: 'x',
|
||||
name: 'Inactive',
|
||||
isActive: false,
|
||||
role: { connect: { key: 'STAFF' } },
|
||||
},
|
||||
});
|
||||
try {
|
||||
for (const assignedUserId of ['no-such-user', inactive.id]) {
|
||||
await expect(
|
||||
service.accept(created.id, { assignedUserId }, userId),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
}
|
||||
expect(await statusOf(created.id)).toBe('OPEN');
|
||||
const ok = await service.accept(
|
||||
created.id,
|
||||
{ assignedUserId: userId },
|
||||
userId,
|
||||
);
|
||||
expect(ok.tasks.every((t) => t.assignedUserId === userId)).toBe(true);
|
||||
} finally {
|
||||
await prisma.task.deleteMany({
|
||||
where: { sourceSuggestionId: created.id },
|
||||
});
|
||||
await prisma.user.delete({ where: { id: inactive.id } });
|
||||
}
|
||||
});
|
||||
|
||||
describe('concurrency', () => {
|
||||
it('accept against dismiss on one suggestion: one wins, the other gets 409 with the status', async () => {
|
||||
const created = (await propose(proposal('c1')))!;
|
||||
|
||||
const settled = await Promise.allSettled([
|
||||
service.accept(created.id, { dueDate: '2026-05-12' }, userId),
|
||||
service.dismiss(created.id, null, userId),
|
||||
]);
|
||||
|
||||
expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||
const [failure] = settled.filter(
|
||||
(r): r is PromiseRejectedResult => r.status === 'rejected',
|
||||
);
|
||||
expect(failure.reason).toMatchObject({ status: 409 });
|
||||
const winner = await statusOf(created.id);
|
||||
expect(['ACCEPTED', 'DISMISSED']).toContain(winner);
|
||||
expect(
|
||||
(failure.reason as { response: { status: string } }).response.status,
|
||||
).toBe(winner);
|
||||
});
|
||||
|
||||
it('two proposals for one colony and intent from different rules leave one row', async () => {
|
||||
const results = await Promise.all([
|
||||
propose({ ...proposal('k1'), ruleKey: 'SPLIT_SCHEDULE' }),
|
||||
propose({
|
||||
...proposal('k2'),
|
||||
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||
source: 'RULE',
|
||||
}),
|
||||
propose({
|
||||
...proposal('k3'),
|
||||
ruleKey: 'INSPECTION_INTERVAL',
|
||||
source: 'AUTOPILOT',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(results.filter((r) => r !== null)).toHaveLength(1);
|
||||
expect(
|
||||
await prisma.suggestion.count({
|
||||
where: { colonyId, intent: 'SPLIT_PLAN', status: 'OPEN' },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('overlapping bulkAccept and accept do not deadlock and stay consistent', async () => {
|
||||
const ids: string[] = [];
|
||||
for (const [index, intent] of (
|
||||
['SPLIT_PLAN', 'QUEEN_ACCEPTANCE', 'FEED'] as const
|
||||
).entries()) {
|
||||
const created = await propose({
|
||||
...proposal(`ov${index}`),
|
||||
ruleKey:
|
||||
intent === 'SPLIT_PLAN'
|
||||
? 'SPLIT_SCHEDULE'
|
||||
: 'QUEEN_ACCEPTANCE_CHECK',
|
||||
intent,
|
||||
plan: null,
|
||||
dueDate: '2026-05-20',
|
||||
});
|
||||
ids.push(created!.id);
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled([
|
||||
service.bulkAccept([ids[0], ids[1], ids[2]], userId),
|
||||
service.accept(ids[1], {}, userId),
|
||||
service.bulkAccept([ids[2], ids[1]], userId),
|
||||
]);
|
||||
|
||||
for (const result of settled) {
|
||||
if (result.status === 'rejected') {
|
||||
expect(result.reason).toMatchObject({ status: 409 });
|
||||
}
|
||||
}
|
||||
expect(settled.some((r) => r.status === 'fulfilled')).toBe(true);
|
||||
for (const id of ids) {
|
||||
// Accepted exactly once, or not at all: never a task without it.
|
||||
const accepted = (await statusOf(id)) === 'ACCEPTED';
|
||||
expect(
|
||||
await prisma.task.count({ where: { sourceSuggestionId: id } }),
|
||||
).toBe(accepted ? 1 : 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 409 for accept and reopen once the colony has ended', async () => {
|
||||
const open = (await propose(proposal('end1')))!;
|
||||
const dismissed = (await propose({
|
||||
...proposal('end2'),
|
||||
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||
intent: 'QUEEN_ACCEPTANCE',
|
||||
plan: null,
|
||||
dueDate: '2026-05-20',
|
||||
}))!;
|
||||
await service.dismiss(dismissed.id, null, userId);
|
||||
await prisma.colony.update({
|
||||
where: { id: colonyId },
|
||||
data: { status: 'SOLD' },
|
||||
});
|
||||
try {
|
||||
await expect(service.accept(open.id, {}, userId)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
await expect(service.reopen(dismissed.id, userId)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
} finally {
|
||||
await prisma.colony.update({
|
||||
where: { id: colonyId },
|
||||
data: { status: 'ACTIVE' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('proposes nothing for a colony that does not exist', async () => {
|
||||
expect(
|
||||
await propose({ ...proposal('nc1'), colonyId: 'no-such-colony' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('bulkAccept answers 400 naming the DO suggestions without a date', async () => {
|
||||
const undated = (await propose({
|
||||
...proposal('bd1'),
|
||||
mode: 'DO',
|
||||
plan: null,
|
||||
dueDate: null,
|
||||
}))!;
|
||||
|
||||
await expect(
|
||||
service.bulkAccept([undated.id], userId),
|
||||
).rejects.toMatchObject({
|
||||
status: 400,
|
||||
response: { issues: [{ field: 'ids', ids: [undated.id] }] },
|
||||
});
|
||||
expect(await statusOf(undated.id)).toBe('OPEN');
|
||||
});
|
||||
|
||||
it('obsoleteForColonyIn closes open suggestions and keeps the key', async () => {
|
||||
const created = (await propose(proposal('x1')))!;
|
||||
|
||||
expect(
|
||||
await prisma.$transaction((tx) =>
|
||||
service.obsoleteForColonyIn(tx, colonyId),
|
||||
),
|
||||
).toBe(1);
|
||||
|
||||
const obsolete = await prisma.suggestion.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(obsolete.status).toBe('OBSOLETE');
|
||||
expect(obsolete.dedupeKey).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
ScratchDatabase,
|
||||
createScratchDatabase,
|
||||
} from './utils/migration-harness';
|
||||
|
||||
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
|
||||
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
|
||||
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
|
||||
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
interface InspectionSeed {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
feedStatus: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
/** Minimal FK targets, written as raw SQL (Prisma ids and updatedAt have no DB default). */
|
||||
async function seedColony(db: ScratchDatabase): Promise<void> {
|
||||
await db.query(
|
||||
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
||||
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "updatedAt")
|
||||
VALUES ('user-1', '[email protected]', 'x', 'Mig', 'role-1', now())`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "Apiary" ("id", "name", "updatedAt")
|
||||
VALUES ('apiary-1', 'Stand', now())`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
|
||||
VALUES ('colony-1', 'V-1', 'apiary-1', 'PRODUCTION_COLONY', now())`,
|
||||
);
|
||||
}
|
||||
|
||||
async function seedInspections(
|
||||
db: ScratchDatabase,
|
||||
seeds: InspectionSeed[],
|
||||
): Promise<void> {
|
||||
for (const seed of seeds) {
|
||||
await db.query(
|
||||
`INSERT INTO "Inspection" ("id", "colonyId", "userId", "date", "feedStatus", "notes", "createdAt")
|
||||
VALUES ($1, 'colony-1', 'user-1', '2026-05-01', $2, $3, $4)`,
|
||||
[seed.id, seed.feedStatus, seed.notes, seed.createdAt],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('T4 schema II migrations (e2e)', () => {
|
||||
let db: ScratchDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (db) {
|
||||
await db.drop();
|
||||
db = null;
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'free-text feed moves into notes and known words map to the enum',
|
||||
async () => {
|
||||
db = await createScratchDatabase(FEED_VOCABULARY);
|
||||
await seedColony(db);
|
||||
await seedInspections(db, [
|
||||
{
|
||||
id: 'i-1',
|
||||
createdAt: '2026-05-01T10:00:00Z',
|
||||
feedStatus: 'niedrig',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: 'i-2',
|
||||
createdAt: '2026-05-01T11:00:00Z',
|
||||
feedStatus: 'halb voll',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: 'i-3',
|
||||
createdAt: '2026-05-01T12:00:00Z',
|
||||
feedStatus: 'halb voll',
|
||||
notes: 'x',
|
||||
},
|
||||
]);
|
||||
|
||||
await db.migrateThrough(FEED_VOCABULARY);
|
||||
|
||||
expect(
|
||||
await db.query(
|
||||
'SELECT "feedStatus", notes FROM "Inspection" ORDER BY "createdAt"',
|
||||
),
|
||||
).toEqual([
|
||||
{ feedStatus: 'WEAK', notes: null },
|
||||
{ feedStatus: null, notes: 'Futter (alt): halb voll' },
|
||||
{ feedStatus: null, notes: 'x\nFutter (alt): halb voll' },
|
||||
]);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'leaves no inspection whose old feed text was lost',
|
||||
async () => {
|
||||
db = await createScratchDatabase(FEED_VOCABULARY);
|
||||
await seedColony(db);
|
||||
const rawSeeds: [string | null, string | null][] = [
|
||||
[' Ausreichend ', null],
|
||||
['KRITISCH', 'Hinweis'],
|
||||
['Niedrig', ''],
|
||||
['sehr viel', 'a'],
|
||||
[' Wabe voll ', null],
|
||||
['', 'leer'],
|
||||
[' ', null],
|
||||
[null, 'ohne'],
|
||||
['\tniedrig', null],
|
||||
['\r\n Halb\tvoll\n', null],
|
||||
];
|
||||
const seeds: InspectionSeed[] = rawSeeds.map(
|
||||
([feedStatus, notes], index) => ({
|
||||
id: `i-${index}`,
|
||||
createdAt: `2026-05-01T0${index}:00:00Z`,
|
||||
feedStatus,
|
||||
notes,
|
||||
}),
|
||||
);
|
||||
await seedInspections(db, seeds);
|
||||
|
||||
await db.migrateThrough(FEED_VOCABULARY);
|
||||
|
||||
const rows = await db.query<{
|
||||
id: string;
|
||||
feedStatus: string | null;
|
||||
notes: string | null;
|
||||
}>('SELECT id, "feedStatus", notes FROM "Inspection" ORDER BY id');
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
|
||||
// Spec 4.12: every old text ends up as an enum value or in the notes.
|
||||
const known = ['ausreichend', 'niedrig', 'kritisch'];
|
||||
for (const seed of seeds) {
|
||||
const text = seed.feedStatus?.trim() ?? '';
|
||||
if (text === '') {
|
||||
continue;
|
||||
}
|
||||
const row = byId.get(seed.id)!;
|
||||
if (known.includes(text.toLowerCase())) {
|
||||
expect(row.feedStatus).not.toBeNull();
|
||||
} else {
|
||||
expect(row.feedStatus).toBeNull();
|
||||
expect(row.notes).toContain(`Futter (alt): ${text}`);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
rows.filter(
|
||||
(row) => row.feedStatus === null && (row.notes ?? '') === '',
|
||||
),
|
||||
).toEqual([
|
||||
// Only the blank text with no notes remains empty - nothing was lost.
|
||||
{ id: 'i-6', feedStatus: null, notes: null },
|
||||
]);
|
||||
expect(byId.get('i-1')?.notes).toBe('Hinweis');
|
||||
expect(byId.get('i-3')?.notes).toBe('a\nFutter (alt): sehr viel');
|
||||
expect(byId.get('i-5')?.notes).toBe('leer');
|
||||
expect(byId.get('i-7')?.notes).toBe('ohne');
|
||||
expect(byId.get('i-8')).toMatchObject({
|
||||
feedStatus: 'WEAK',
|
||||
notes: null,
|
||||
});
|
||||
expect(byId.get('i-9')).toMatchObject({
|
||||
feedStatus: null,
|
||||
notes: 'Futter (alt): Halb\tvoll',
|
||||
});
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'adds the optional split queen source and varroa sample size without touching old rows',
|
||||
async () => {
|
||||
db = await createScratchDatabase(SPLIT_QUEEN_SOURCE);
|
||||
await seedColony(db);
|
||||
await db.query(
|
||||
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
|
||||
VALUES ('colony-2', 'V-2', 'apiary-1', 'NUCLEUS_COLONY', now())`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "ColonyLineage" ("id", "type", "fromColonyId", "toColonyId", "date")
|
||||
VALUES ('l-1', 'SPLIT', 'colony-1', 'colony-2', '2026-05-01')`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "VarroaMeasurement" ("id", "colonyId", "userId", "date", "method", "miteCount")
|
||||
VALUES ('v-1', 'colony-1', 'user-1', '2026-05-02', 'WASHING_METHOD', 4)`,
|
||||
);
|
||||
|
||||
await db.migrateThrough(SPLIT_QUEEN_SOURCE);
|
||||
await db.migrateThrough(VARROA_SAMPLE_SIZE);
|
||||
|
||||
expect(
|
||||
await db.query('SELECT "queenSource" FROM "ColonyLineage"'),
|
||||
).toEqual([{ queenSource: null }]);
|
||||
expect(
|
||||
await db.query(
|
||||
'SELECT "sampleBees", "miteCount" FROM "VarroaMeasurement"',
|
||||
),
|
||||
).toEqual([{ sampleBees: null, miteCount: 4 }]);
|
||||
await db.query(
|
||||
`UPDATE "ColonyLineage" SET "queenSource" = 'RAISE_OWN' WHERE id = 'l-1'`,
|
||||
);
|
||||
await expect(
|
||||
db.query(
|
||||
`UPDATE "ColonyLineage" SET "queenSource" = 'BOUGHT' WHERE id = 'l-1'`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { Client } from 'pg';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const BACKEND_ROOT = path.resolve(__dirname, '../..');
|
||||
const MIGRATIONS_DIR = path.join(BACKEND_ROOT, 'prisma', 'migrations');
|
||||
const SCHEMA_FILE = path.join(BACKEND_ROOT, 'prisma', 'schema.prisma');
|
||||
const SCRATCH_ROOT = path.join(BACKEND_ROOT, '.tmp-migrations');
|
||||
const PRISMA_CLI = path.join(
|
||||
BACKEND_ROOT,
|
||||
'node_modules',
|
||||
'prisma',
|
||||
'build',
|
||||
'index.js',
|
||||
);
|
||||
const DEPLOY_TIMEOUT_MS = 100_000;
|
||||
const SCHEMA_NAME_PATTERN = /^mig_[0-9a-f]{12}$/;
|
||||
|
||||
export interface ScratchDatabase {
|
||||
readonly schema: string;
|
||||
query<T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<T[]>;
|
||||
/** Applies every migration up to and including the named one. */
|
||||
migrateThrough(migrationName: string): Promise<void>;
|
||||
drop(): Promise<void>;
|
||||
}
|
||||
|
||||
function baseConnectionString(): string {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error('DATABASE_URL is not set');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function withSchema(connectionString: string, schema: string): string {
|
||||
const url = new URL(connectionString);
|
||||
url.searchParams.set('schema', schema);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function withoutSchemaParam(connectionString: string): string {
|
||||
const url = new URL(connectionString);
|
||||
url.searchParams.delete('schema');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function listMigrationNames(): Promise<string[]> {
|
||||
const entries = await fs.readdir(MIGRATIONS_DIR, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fresh Postgres schema and migrates it up to, but not including,
|
||||
* `beforeMigration`. The migration folders are copied below
|
||||
* `.tmp-migrations/` inside the backend so that the prisma config resolves
|
||||
* the installed `prisma` package.
|
||||
*/
|
||||
export async function createScratchDatabase(
|
||||
beforeMigration: string,
|
||||
): Promise<ScratchDatabase> {
|
||||
const names = await listMigrationNames();
|
||||
const target = names.indexOf(beforeMigration);
|
||||
if (target < 0) {
|
||||
throw new Error(`Unknown migration: ${beforeMigration}`);
|
||||
}
|
||||
|
||||
const id = randomBytes(6).toString('hex');
|
||||
const schema = `mig_${id}`;
|
||||
if (!SCHEMA_NAME_PATTERN.test(schema)) {
|
||||
throw new Error('Unexpected scratch schema name');
|
||||
}
|
||||
const workDir = path.join(SCRATCH_ROOT, id);
|
||||
const scratchUrl = withSchema(baseConnectionString(), schema);
|
||||
|
||||
const admin = new Client({
|
||||
connectionString: withoutSchemaParam(baseConnectionString()),
|
||||
});
|
||||
await admin.connect();
|
||||
await admin.query(`CREATE SCHEMA "${schema}"`);
|
||||
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
await fs.copyFile(
|
||||
path.join(MIGRATIONS_DIR, 'migration_lock.toml'),
|
||||
path.join(workDir, 'migration_lock.toml'),
|
||||
);
|
||||
// The URL reaches the config through the environment, never through disk.
|
||||
await fs.writeFile(
|
||||
path.join(workDir, 'prisma.config.ts'),
|
||||
[
|
||||
"import { defineConfig } from 'prisma/config';",
|
||||
'',
|
||||
'export default defineConfig({',
|
||||
` schema: ${JSON.stringify(SCHEMA_FILE)},`,
|
||||
` migrations: { path: ${JSON.stringify(workDir)} },`,
|
||||
" datasource: { url: process.env['MIGRATION_HARNESS_URL'] },",
|
||||
'});',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const copied = new Set<string>();
|
||||
const deploy = async (upToExclusive: number): Promise<void> => {
|
||||
for (const name of names.slice(0, upToExclusive)) {
|
||||
if (!copied.has(name)) {
|
||||
await fs.cp(path.join(MIGRATIONS_DIR, name), path.join(workDir, name), {
|
||||
recursive: true,
|
||||
});
|
||||
copied.add(name);
|
||||
}
|
||||
}
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
PRISMA_CLI,
|
||||
'migrate',
|
||||
'deploy',
|
||||
'--config',
|
||||
path.join(workDir, 'prisma.config.ts'),
|
||||
],
|
||||
{
|
||||
cwd: BACKEND_ROOT,
|
||||
env: { ...process.env, MIGRATION_HARNESS_URL: scratchUrl },
|
||||
timeout: DEPLOY_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let client: Client | null = null;
|
||||
const connected = async (): Promise<Client> => {
|
||||
if (!client) {
|
||||
client = new Client({
|
||||
connectionString: withoutSchemaParam(baseConnectionString()),
|
||||
});
|
||||
await client.connect();
|
||||
await client.query(`SET search_path TO "${schema}"`);
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
try {
|
||||
await deploy(target);
|
||||
} catch (error) {
|
||||
await fs.rm(workDir, { recursive: true, force: true });
|
||||
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
|
||||
await admin.end();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
schema,
|
||||
async query<T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
const active = await connected();
|
||||
const result = await active.query(sql, params);
|
||||
return result.rows as T[];
|
||||
},
|
||||
async migrateThrough(migrationName: string): Promise<void> {
|
||||
const index = names.indexOf(migrationName);
|
||||
if (index < 0) {
|
||||
throw new Error(`Unknown migration: ${migrationName}`);
|
||||
}
|
||||
await deploy(index + 1);
|
||||
},
|
||||
async drop(): Promise<void> {
|
||||
if (client) {
|
||||
await client.end();
|
||||
client = null;
|
||||
}
|
||||
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
|
||||
await admin.end();
|
||||
await fs.rm(workDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user