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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 13:42:32 +02:00
1 parent 7097fcd97c
commit d99a697739
116 files changed
+11239 -801

No files matched your search

+91 -2
View File
@@ -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,
);
});
+217
View File
@@ -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 });
},
};
}