Files
ApiCore/ApiCore-Backend/test/t4-migrations.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 f468dc5f75 feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:25:24 +02:00

475 lines
15 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 NOTIFICATION_FANOUT = '20261014100500_t4_notification_fanout';
const OCCUPIED_FRAMES = '20261014100700_t4_occupied_frames';
const NOTIFICATION_SETTINGS = '20261014100800_t4_notification_settings';
const TIMEOUT_MS = 240_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())`,
);
}
/** Two active users and one inactive one, plus the role they need. */
async function seedUsers(db: ScratchDatabase): Promise<void> {
await db.query(
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
);
for (const [id, active] of [
['user-a', true],
['user-b', true],
['user-c', false],
] as const) {
await db.query(
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt")
VALUES ($1, $2, 'x', $1, 'role-1', $3, now())`,
[id, `${id}@example.com`, active],
);
}
}
async function seedNotification(
db: ScratchDatabase,
row: {
id: string;
userId: string | null;
relatedType: string | null;
read?: boolean;
createdAt?: string;
},
): Promise<void> {
await db.query(
`INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "read", "createdAt")
VALUES ($1, $2, 'WARNUNG', 'm', $3, $4, $5)`,
[
row.id,
row.userId,
row.relatedType,
row.read ?? false,
row.createdAt ?? '2026-05-01T10:00:00Z',
],
);
}
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,
);
it(
'broadcasts are copied per active user with their read flag',
async () => {
db = await createScratchDatabase(NOTIFICATION_FANOUT);
await seedUsers(db);
await seedNotification(db, {
id: 'broadcast',
userId: null,
relatedType: 'COLONY',
read: true,
createdAt: '2026-04-01T08:00:00Z',
});
await seedNotification(db, {
id: 'personal',
userId: 'user-c',
relatedType: 'COLONY',
});
const [{ createdAt }] = await db.query<{ createdAt: string }>(
`SELECT "createdAt"::text AS "createdAt" FROM "Notification" WHERE "id" = 'broadcast'`,
);
await db.migrateThrough(NOTIFICATION_FANOUT);
const rows = await db.query<{
userId: string;
read: boolean;
createdAt: string;
}>(
`SELECT "userId", "read", "createdAt"::text AS "createdAt"
FROM "Notification" `,
);
rows.sort((a, b) => (a.userId < b.userId ? -1 : 1));
expect(rows.map(({ userId, read }) => ({ userId, read }))).toEqual([
{ userId: 'user-a', read: true },
{ userId: 'user-b', read: true },
{ userId: 'user-c', read: false },
]);
expect(rows[0].createdAt).toBe(createdAt);
expect(rows[1].createdAt).toBe(createdAt);
expect(
await db.query(
`SELECT is_nullable FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'userId'`,
),
).toEqual([{ is_nullable: 'NO' }]);
},
TIMEOUT_MS,
);
it(
'drops broadcasts when no user is active and keeps the personal rows',
async () => {
db = await createScratchDatabase(NOTIFICATION_FANOUT);
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", "isActive", "updatedAt")
VALUES ('user-x', '[email protected]', 'x', 'x', 'role-1', false, now())`,
);
await seedNotification(db, {
id: 'broadcast',
userId: null,
relatedType: null,
});
await seedNotification(db, {
id: 'personal',
userId: 'user-x',
relatedType: null,
});
await db.migrateThrough(NOTIFICATION_FANOUT);
expect(await db.query('SELECT "id" FROM "Notification"')).toEqual([
{ id: 'personal' },
]);
},
TIMEOUT_MS,
);
it(
'copies carry no dedupeKey, old rows get empty requiredPermissions, and the preference defaults hold',
async () => {
db = await createScratchDatabase(NOTIFICATION_FANOUT);
await seedUsers(db);
await seedNotification(db, {
id: 'broadcast',
userId: null,
relatedType: 'COLONY',
});
await seedNotification(db, {
id: 'personal',
userId: 'user-a',
relatedType: 'COLONY',
});
await db.migrateThrough(NOTIFICATION_SETTINGS);
expect(
await db.query(
`SELECT count(*)::int AS "rows",
count("dedupeKey")::int AS "keys",
count(*) FILTER (WHERE "requiredPermissions" = '{}')::int AS "emptyPermissions",
count("link")::int AS "links"
FROM "Notification"`,
),
).toEqual([{ rows: 3, keys: 0, emptyPermissions: 3, links: 0 }]);
await db.query(
`INSERT INTO "NotificationPreference" ("userId", "updatedAt") VALUES ('user-a', now())`,
);
expect(
await db.query(
`SELECT "taskDueEnabled", "digestEnabled", "digestTime", "quietFrom", "quietUntil",
"telegramEnabled", "telegramCategories"::text AS "telegramCategories", "telegramDetail"
FROM "NotificationPreference"`,
),
).toEqual([
{
taskDueEnabled: true,
digestEnabled: true,
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
telegramEnabled: false,
telegramCategories: '{TASK_DUE,DIGEST}',
telegramDetail: true,
},
]);
},
TIMEOUT_MS,
);
it(
'legacy job-failure rows (relatedType JobRun) and rows without relatedType get SYSTEM, all others SUGGESTION, before NOT NULL',
async () => {
db = await createScratchDatabase(NOTIFICATION_FANOUT);
await seedUsers(db);
await seedNotification(db, {
id: 'n-job',
userId: 'user-a',
relatedType: 'JobRun',
});
await seedNotification(db, {
id: 'n-none',
userId: 'user-a',
relatedType: null,
});
await seedNotification(db, {
id: 'n-colony',
userId: 'user-a',
relatedType: 'COLONY',
});
await seedNotification(db, {
id: 'n-broadcast',
userId: null,
relatedType: 'JobRun',
});
await db.migrateThrough(NOTIFICATION_SETTINGS);
expect(
await db.query(
`SELECT "id", "category" FROM "Notification" WHERE "id" LIKE 'n-%' ORDER BY "id"`,
),
).toEqual([
{ id: 'n-colony', category: 'SUGGESTION' },
{ id: 'n-job', category: 'SYSTEM' },
{ id: 'n-none', category: 'SYSTEM' },
]);
// The broadcast copies carry relatedType JobRun too.
expect(
await db.query(
`SELECT DISTINCT "category" FROM "Notification" WHERE "relatedType" = 'JobRun' AND "id" NOT LIKE 'n-%'`,
),
).toEqual([{ category: 'SYSTEM' }]);
expect(
await db.query(
`SELECT is_nullable FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'category'`,
),
).toEqual([{ is_nullable: 'NO' }]);
},
TIMEOUT_MS,
);
it(
'occupied frames migration leaves strength untouched',
async () => {
db = await createScratchDatabase(OCCUPIED_FRAMES);
await seedColony(db);
await db.query(`UPDATE "Colony" SET "strength" = 7`);
await db.migrateThrough(OCCUPIED_FRAMES);
expect(
await db.query('SELECT "strength", "occupiedFrames" FROM "Colony"'),
).toEqual([{ strength: 7, occupiedFrames: null }]);
await db.query(`UPDATE "Colony" SET "occupiedFrames" = 40`);
await expect(
db.query(`UPDATE "Colony" SET "occupiedFrames" = 41`),
).rejects.toThrow();
await expect(
db.query(`UPDATE "Colony" SET "occupiedFrames" = -1`),
).rejects.toThrow();
},
TIMEOUT_MS,
);
});