Files
HealthLog/tests/integration/mood-a1-backfill.test.ts
T
Marc-André Bombeck 643d8a5a00 feat(mood): map existing entries onto the pleasantness scale
- the migration fills mood_a1 from the five-point label for every row
  that has none, and leaves the other four columns empty because there
  is nothing in a label to derive them from
- guarded on the column being empty, so re-applying the migration is a
  no-op rather than an overwrite of an answer somebody gave since
- the test reads the CASE arms back out of the .sql and compares them to
  the runtime map in both directions, so neither can drift alone
- rows are asserted by value after the statement runs, and again after a
  second run, including one hand-set value that must survive it
2026-08-08 01:03:05 +02:00

230 lines
8.3 KiB
TypeScript

/**
* v1.37 — the level-A backfill, read out of the migration and executed.
*
* The statement that maps a hundred thousand historical rows onto the
* pleasantness scale carries its own copy of five numbers, and the runtime map
* carries the other. Two copies of the same five numbers is a drift waiting to
* happen, so this test reads the arms back out of the `.sql` file and compares
* them to `MOOD_A1_MAP` arm by arm — a migration edited without the map, or a
* map edited without the migration, fails here.
*
* The rest of it executes the statement against real Postgres on rows inserted
* the way legacy rows actually look (`mood_a1 IS NULL`), and asserts the rows,
* not the fact that the statement ran. It runs a second time and asserts
* nothing moved: a migration re-applied against a database that has since
* taken real answers must leave them alone.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { moodA1Map } from "@/lib/validations/mood";
import { getPrismaClient, truncateAllTables } from "./setup";
const MIGRATION_PATH = path.join(
process.cwd(),
"prisma",
"migrations",
"0310_mood_level_a_dimensions",
"migration.sql",
);
const USER = "user-mood-a1-backfill";
function migrationSql(): string {
return readFileSync(MIGRATION_PATH, "utf8");
}
/** The `UPDATE mood_entries … ;` statement, comments stripped. */
function backfillStatement(sql: string): string {
const withoutComments = sql
.split("\n")
.filter((line) => !line.trimStart().startsWith("--"))
.join("\n");
const match = withoutComments.match(/UPDATE\s+mood_entries[\s\S]*?;/i);
if (!match) {
throw new Error(
`No UPDATE statement found in ${MIGRATION_PATH}. The backfill is the point of this test; an absent statement is a failure, not a skip.`,
);
}
return match[0];
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("mood a1 backfill — the statement agrees with the runtime map", () => {
it("carries one CASE arm per label, with the value the map answers", () => {
const statement = backfillStatement(migrationSql());
const arms = [
...statement.matchAll(/WHEN\s+'([A-Z_]+)'\s+THEN\s+(\d+)/gi),
].map((m) => [m[1], Number(m[2])] as const);
// An empty match set means the matcher stopped matching, not that the
// migration is correct. A guard that cannot fail is worse than none.
expect(arms.length).toBeGreaterThan(0);
const map = moodA1Map();
expect(arms.length).toBe(Object.keys(map).length);
for (const [label, value] of arms) {
expect(
map[label],
`CASE arm '${label}' has no entry in MOOD_A1_MAP`,
).toBe(value);
}
// And the other direction: a label in the map with no arm would leave
// those rows NULL after the backfill claimed to have covered them.
for (const label of Object.keys(map)) {
expect(
arms.map(([l]) => l),
`MOOD_A1_MAP entry '${label}' has no CASE arm in the migration`,
).toContain(label);
}
});
it("is guarded on the column being empty, so a re-run cannot overwrite an answer", () => {
const statement = backfillStatement(migrationSql());
expect(statement.replace(/\s+/g, " ")).toMatch(
/WHERE\s+mood_a1\s+IS\s+NULL/i,
);
});
});
describe("mood a1 backfill — executed against real rows", () => {
async function seedLegacyRows() {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: USER,
username: "mood-a1-backfill",
email: "mood-a1-backfill@example.test",
timezone: "Europe/Berlin",
},
});
// Inserted through raw SQL so the columns look exactly like a row written
// before they existed: every level-A column NULL. Going through Prisma
// would let a default or a hook fill one in and the test would be
// proving the seed rather than the migration.
const rows: Array<[string, string, number, string | null]> = [
["legacy-lausig", "LAUSIG", 1, null],
["legacy-schlecht", "SCHLECHT", 2, null],
["legacy-okay", "OKAY", 3, null],
["legacy-gut", "GUT", 4, null],
["legacy-super", "SUPER_GUT", 5, null],
// A tombstoned row is still the user's history and still restorable.
["legacy-deleted", "GUT", 4, "2026-05-01T00:00:00.000Z"],
// A label nothing recognises: no arm matches, so it stays NULL rather
// than being handed the midpoint.
["legacy-unknown", "NOT_A_MOOD", 3, null],
];
for (const [index, [id, mood, score, deletedAt]] of rows.entries()) {
// An explicit instant per row, one hour apart. `NOW()` would be the
// transaction's own clock, and two inserts landing on the same tick
// collide on the `(user_id, date, mood_logged_at)` unique — a seed that
// fails on a fast machine and passes on a slow one.
const loggedAt = `2026-05-16T0${index}:00:00.000Z`;
await prisma.$executeRawUnsafe(
`INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, created_at, updated_at, sync_version, deleted_at)
VALUES ($1, $2, '2026-05-16', $3, $4, 'MANUAL', $5::timestamptz, NOW(), NOW(), NOW(), 0, $6::timestamptz)`,
id,
USER,
mood,
score,
loggedAt,
deletedAt,
);
}
const before = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*)::bigint AS count FROM mood_entries WHERE mood_a1 IS NULL`,
);
expect(Number(before[0].count)).toBe(rows.length);
return rows.length;
}
it("maps every legacy row onto its anchor and leaves the other four empty", async () => {
const prisma = getPrismaClient();
const seeded = await seedLegacyRows();
const changed = await prisma.$executeRawUnsafe(
backfillStatement(migrationSql()),
);
// Every row but the unknown label — the statement's IN list excludes it.
expect(changed).toBe(seeded - 1);
const after = await prisma.moodEntry.findMany({
where: { userId: USER },
orderBy: { id: "asc" },
select: {
id: true,
mood: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
},
});
expect(after).toHaveLength(seeded);
const byId = new Map(after.map((r) => [r.id, r]));
expect(byId.get("legacy-lausig")?.moodA1).toBe(1);
expect(byId.get("legacy-schlecht")?.moodA1).toBe(3);
expect(byId.get("legacy-okay")?.moodA1).toBe(5);
expect(byId.get("legacy-gut")?.moodA1).toBe(7);
expect(byId.get("legacy-super")?.moodA1).toBe(9);
// A soft-deleted row is backfilled too: it is still in the backup and
// still comes back from a restore.
expect(byId.get("legacy-deleted")?.moodA1).toBe(7);
// An unrecognised label gets nothing rather than a fabricated midpoint.
expect(byId.get("legacy-unknown")?.moodA1).toBeNull();
// Nothing derived the other four, on any row.
for (const row of after) {
expect(row.stressA2).toBeNull();
expect(row.energyA3).toBeNull();
expect(row.connectionA4).toBeNull();
expect(row.stabilityA5).toBeNull();
}
});
it("changes nothing on a second run, including a value set since the first", async () => {
const prisma = getPrismaClient();
await seedLegacyRows();
const statement = backfillStatement(migrationSql());
await prisma.$executeRawUnsafe(statement);
// Somebody answers the slider on one of the backfilled rows, contradicting
// the mapped value. A re-run must not take that answer away.
await prisma.moodEntry.update({
where: { id: "legacy-okay" },
data: { moodA1: 8 },
});
const beforeSecond = await prisma.moodEntry.findMany({
where: { userId: USER },
orderBy: { id: "asc" },
select: { id: true, moodA1: true, updatedAt: true },
});
const changedOnSecondRun = await prisma.$executeRawUnsafe(statement);
expect(changedOnSecondRun).toBe(0);
const afterSecond = await prisma.moodEntry.findMany({
where: { userId: USER },
orderBy: { id: "asc" },
select: { id: true, moodA1: true, updatedAt: true },
});
expect(afterSecond).toEqual(beforeSecond);
expect(
afterSecond.find((r) => r.id === "legacy-okay")?.moodA1,
"the hand-set answer survived the re-run",
).toBe(8);
});
});