From 643d8a5a00b5f407db246093b63918ad1ea911f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:03:05 +0200 Subject: [PATCH] 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 --- .../migration.sql | 28 +++ tests/integration/mood-a1-backfill.test.ts | 229 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 tests/integration/mood-a1-backfill.test.ts diff --git a/prisma/migrations/0310_mood_level_a_dimensions/migration.sql b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql index 173bc03f2..e07fec8ee 100644 --- a/prisma/migrations/0310_mood_level_a_dimensions/migration.sql +++ b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql @@ -19,3 +19,31 @@ ALTER TABLE "mood_entries" ADD COLUMN "energy_a3" INTEGER, ADD COLUMN "connection_a4" INTEGER, ADD COLUMN "stability_a5" INTEGER; + +-- Map every entry written before these columns existed onto the pleasantness +-- scale. The five-point label is the only thing a historical row can answer +-- from, so it is the only column filled: stress, energy, connectedness and +-- stability stay NULL, because there is nothing to derive them from and an +-- invented number would be indistinguishable from one somebody gave. +-- +-- The anchors are 1/3/5/7/9, matching `MOOD_A1_MAP` in +-- `src/lib/validations/mood.ts` — the runtime map and these arms are compared +-- to each other by a test, so the two cannot drift apart. Not 0 and 10 at the +-- ends: the five-point endpoints mean as bad or as good as that instrument +-- can express, not as bad or as good as a person can feel, and pinning them +-- to the extremes would make every mapped row read as more extreme than every +-- hand-set one. +-- +-- `WHERE mood_a1 IS NULL` makes a re-run a no-op instead of an overwrite. A +-- migration re-applied against a database that has since taken real answers +-- must not replace them with the mapped value. +UPDATE mood_entries + SET mood_a1 = CASE mood + WHEN 'LAUSIG' THEN 1 + WHEN 'SCHLECHT' THEN 3 + WHEN 'OKAY' THEN 5 + WHEN 'GUT' THEN 7 + WHEN 'SUPER_GUT' THEN 9 + END + WHERE mood_a1 IS NULL + AND mood IN ('LAUSIG', 'SCHLECHT', 'OKAY', 'GUT', 'SUPER_GUT'); diff --git a/tests/integration/mood-a1-backfill.test.ts b/tests/integration/mood-a1-backfill.test.ts new file mode 100644 index 000000000..a98efdac3 --- /dev/null +++ b/tests/integration/mood-a1-backfill.test.ts @@ -0,0 +1,229 @@ +/** + * 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>( + `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); + }); +});