mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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
This commit is contained in:
1 parent
a1c78eceef
commit
643d8a5a00
2 files changed
+257
No files matched your search
@@ -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');
|
||||
@@ -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<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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user