- 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]>
219 lines
6.7 KiB
TypeScript
219 lines
6.7 KiB
TypeScript
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,
|
|
);
|
|
});
|