From 9388f1d032671dc4a06adcefff87fd37536c88ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 21:44:01 +0200 Subject: [PATCH] test(backup): round-trip a mood entry through the real restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeds an entry with a ticked tag, a rated factor, an encrypted note, a non-Berlin timezone and a sync counter above zero, exports through the real builder, restores through the real `POST /api/admin/backups/[id]/restore`, and reads the row back out of Postgres. Values, not a payload shape: this repository has already proved that asserting a `moodEntries` key stays green with the restore call excised. Two more cases beside it. A hand-written payload in the shape files were written in before the note, zone, counter and tag keys rode along — the compatibility floor, and the only way to know it holds is to send one. And a ticked tag this instance cannot resolve, which has to come back named in the skip report rather than silently absent. Seen red three times on the way in: re-adding the tag-link filter loses the ticked tags, dropping `tz` from the restore write loses the zone, dropping the note ciphertext from the payload loses the text. Each reverted and the file's hash checked back to its recorded value. --- .../mood-backup-round-trip.test.ts | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 tests/integration/mood-backup-round-trip.test.ts diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts new file mode 100644 index 000000000..42c43e088 --- /dev/null +++ b/tests/integration/mood-backup-round-trip.test.ts @@ -0,0 +1,439 @@ +/** + * A mood entry, out through the real backup and back in through the real + * restore, asserted on the rows. + * + * Deliberately not a classification test. Asserting that the payload has a + * `moodEntries` key proves nothing this repository has not already been burned + * by: excise the restore call entirely and a shape check still reports green. + * So this seeds an account, exports through `buildFullBackupPayload`, parses + * through `parseBackupPayload`, restores through the real + * `POST /api/admin/backups/[id]/restore`, and then reads the row back out of + * Postgres and compares values. + * + * The fixture is the entry that used to come back wrong in four ways at once: + * a ticked BINARY tag and a rated factor on the same entry, a note in the + * encrypted column, a timezone that is not the legacy Europe/Berlin default, + * and a sync counter above zero. The old payload carried none of them. + * + * The second case is the compatibility floor: a hand-written payload in the + * shape files were written in BEFORE this change, restored through the same + * route. Every field this adds is optional or defaulted precisely so that file + * keeps working, and the only way to know is to send one. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { encrypt } from "@/lib/crypto"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { encryptNote } from "@/lib/crypto/note-cipher"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +const OWNER_ID = "mood-round-trip-owner"; +const NOTE = "slept badly, long walk in the afternoon"; +const LOGGED_AT = new Date("2026-07-19T23:40:00.000Z"); +const CREATED_AT = new Date("2026-07-19T23:41:00.000Z"); +const UPDATED_AT = new Date("2026-07-20T06:05:00.000Z"); +const DELETED_AT = new Date("2026-07-20T07:00:00.000Z"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seedAdminSession(prisma: PrismaClient) { + const admin = await prisma.user.create({ + data: { + username: "mood-round-trip-admin", + email: "mood-round-trip-admin@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return admin; +} + +async function seedOwner(prisma: PrismaClient) { + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "mood-round-trip-owner", + email: "mood-round-trip-owner@example.test", + }, + }); +} + +/** + * The taxonomy the entry links to: one seeded BINARY tag (`userId: null`, the + * reference data every instance has), one BINARY tag the account made itself, + * and one RATED factor. The account's own tag matters — it travels in the file + * as a `customMoodTags` row, and a restore that could not re-create it would + * lose the link even with everything else correct. + */ +async function seedTaxonomy(prisma: PrismaClient) { + const category = await prisma.moodTagCategory.create({ + data: { + id: "mood-rt-category", + key: "mood_round_trip", + labelKey: "mood.tagCategory.roundTrip", + }, + }); + const seededTag = await prisma.moodTag.create({ + data: { + id: "mood-rt-seeded-tag", + categoryId: category.id, + key: "rt_headache", + labelKey: "mood.tag.rtHeadache", + kind: "BINARY", + }, + }); + const ownTag = await prisma.moodTag.create({ + data: { + id: "mood-rt-own-tag", + userId: OWNER_ID, + categoryId: category.id, + key: "custom:rt_long_walk", + labelKey: "mood.tag.custom", + kind: "BINARY", + }, + }); + const factor = await prisma.moodTag.create({ + data: { + id: "mood-rt-factor", + categoryId: category.id, + key: "rt_sleep_quality", + labelKey: "mood.tag.rtSleepQuality", + kind: "RATED", + scaleMin: 1, + scaleMax: 5, + }, + }); + return { category, seededTag, ownTag, factor }; +} + +function makeRestoreRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +async function restoreFromPayload( + prisma: PrismaClient, + payload: unknown, + type: string, +) { + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type, + data: encrypt(JSON.stringify(payload)), + }, + }); + return POST(makeRestoreRequest(backup.id) as never, { + params: Promise.resolve({ id: backup.id }), + }); +} + +describe("mood backup round trip", () => { + it("brings the note, the timezone, the sync counter and both tag arms back", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + const { seededTag, ownTag, factor } = await seedTaxonomy(prisma); + + await prisma.moodEntry.create({ + data: { + id: "mood-rt-entry", + userId: OWNER_ID, + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: JSON.stringify(["free-text-tag"]), + note: null, + noteEncrypted: encryptNote(NOTE), + source: "MANUAL", + externalId: null, + moodLoggedAt: LOGGED_AT, + // Not Europe/Berlin: a restore that drops `tz` moves this entry's day + // boundary by six hours and nothing else in the row shows it. + tz: "America/New_York", + syncVersion: 3, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + tagLinks: { + create: [ + { moodTagId: factor.id, rating: 4 }, + { moodTagId: seededTag.id, rating: null }, + { moodTagId: ownTag.id, rating: null }, + ], + }, + }, + }); + await prisma.moodEntry.create({ + data: { + id: "mood-rt-tombstone", + userId: OWNER_ID, + date: "2026-07-18", + mood: "SCHLECHT", + score: 2, + source: "MANUAL", + moodLoggedAt: new Date("2026-07-18T22:00:00.000Z"), + tz: "America/New_York", + syncVersion: 5, + deletedAt: DELETED_AT, + }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(payload); + + // Wipe what the restore is supposed to bring back. The tag rows stay: the + // instance's catalogue is reference data, and the account's own tag rides + // in the file and is re-created by the restore either way. + await prisma.moodEntry.deleteMany({ where: { userId: OWNER_ID } }); + await prisma.moodTag.deleteMany({ where: { userId: OWNER_ID } }); + expect(await prisma.moodEntry.count({ where: { userId: OWNER_ID } })).toBe( + 0, + ); + + const response = await restoreFromPayload( + prisma, + parsed, + "MOOD_ROUND_TRIP", + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { skipped: { links: number } }; + }; + // Nothing was dropped. A restore that lost a link and still answered 200 + // is exactly what the skip report exists to make visible. + expect(body.data.skipped.links).toBe(0); + + const restored = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-entry" }, + }); + // Asserted as "there is a note" before it is decrypted, so a payload that + // stopped carrying the ciphertext fails by name rather than as a TypeError + // three frames inside the cipher. + expect( + restored.noteEncrypted, + "the restored entry has no note ciphertext — the backup dropped it", + ).not.toBeNull(); + expect(decryptFromBytes(restored.noteEncrypted!)).toBe(NOTE); + expect(restored.note).toBeNull(); + expect(restored.tz).toBe("America/New_York"); + expect(restored.syncVersion).toBe(3); + expect(restored.moodLoggedAt).toEqual(LOGGED_AT); + expect(restored.createdAt).toEqual(CREATED_AT); + expect(restored.updatedAt).toEqual(UPDATED_AT); + expect(restored.date).toBe("2026-07-19"); + expect(restored.score).toBe(3); + expect(restored.tags).toBe(JSON.stringify(["free-text-tag"])); + expect(restored.deletedAt).toBeNull(); + + const tombstone = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-tombstone" }, + }); + expect(tombstone.deletedAt).toEqual(DELETED_AT); + expect(tombstone.syncVersion).toBe(5); + + const links = await prisma.moodEntryTagLink.findMany({ + where: { moodEntryId: "mood-rt-entry" }, + select: { rating: true, moodTag: { select: { key: true, kind: true } } }, + orderBy: { moodTagId: "asc" }, + }); + const byKey = new Map(links.map((l) => [l.moodTag.key, l])); + expect([...byKey.keys()].sort()).toEqual([ + "custom:rt_long_walk", + "rt_headache", + "rt_sleep_quality", + ]); + // The rating is what tells the two arms apart in the row: a ticked tag + // carries NULL, and that NULL is what the old backup filter read as + // "nothing to carry". + expect(byKey.get("rt_headache")!.rating).toBeNull(); + expect(byKey.get("custom:rt_long_walk")!.rating).toBeNull(); + expect(byKey.get("rt_sleep_quality")!.rating).toBe(4); + }); + + it("restores a file written in the shape that predates these fields", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + const { factor } = await seedTaxonomy(prisma); + + // Verbatim the shape the builder emitted before this change: no `note`, no + // `noteEncrypted`, no `tz`, no `syncedAt`, no `syncVersion`, no + // `createdAt`/`updatedAt`, no `structuredTags`. Hand-written rather than + // generated, because a fixture built from today's builder cannot describe + // yesterday's file. + const legacyPayload = { + schemaVersion: "2", + exportedAt: "2026-07-20T00:00:00.000Z", + userId: OWNER_ID, + appSettings: null, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: "mood-rt-legacy", + date: "2026-07-19", + mood: "GUT", + score: 4, + tags: null, + source: "MOODLOG", + loggedAt: LOGGED_AT.toISOString(), + externalId: "moodlog-legacy", + deletedAt: null, + factors: [{ key: factor.key, rating: 5 }], + }, + ], + customMoodTags: [], + nutrientDays: [], + }; + + const parsed = parseBackupPayload(legacyPayload); + // Defaults, not values: absence has to stay absence through the parse. + expect(parsed.moodEntries[0].structuredTags).toEqual([]); + expect(parsed.moodEntries[0].tz).toBeUndefined(); + expect(parsed.moodEntries[0].syncVersion).toBeUndefined(); + + const response = await restoreFromPayload( + prisma, + parsed, + "MOOD_LEGACY_SHAPE", + ); + expect(response.status).toBe(200); + + const restored = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-legacy" }, + }); + // Identical to what the old restore wrote for the same file: the note + // columns empty, `tz` NULL — which is what such a row meant, the legacy + // Europe/Berlin reading — and the counter at the schema default. + expect(restored.note).toBeNull(); + expect(restored.noteEncrypted).toBeNull(); + expect(restored.tz).toBeNull(); + expect(restored.syncVersion).toBe(0); + expect(restored.mood).toBe("GUT"); + expect(restored.score).toBe(4); + expect(restored.externalId).toBe("moodlog-legacy"); + expect(restored.deletedAt).toBeNull(); + + const links = await prisma.moodEntryTagLink.findMany({ + where: { moodEntryId: "mood-rt-legacy" }, + select: { rating: true, moodTagId: true }, + }); + expect(links).toEqual([{ moodTagId: factor.id, rating: 5 }]); + }); + + it("names a ticked tag this instance cannot resolve instead of losing it quietly", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + await seedTaxonomy(prisma); + + const payload = { + schemaVersion: "2", + exportedAt: "2026-07-20T00:00:00.000Z", + userId: OWNER_ID, + appSettings: null, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: "mood-rt-unknown-tag", + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: null, + source: "MANUAL", + loggedAt: LOGGED_AT.toISOString(), + externalId: null, + deletedAt: null, + factors: [], + structuredTags: ["rt_headache", "rt_retired_tag"], + }, + ], + customMoodTags: [], + nutrientDays: [], + }; + + const response = await restoreFromPayload( + prisma, + parseBackupPayload(payload), + "MOOD_UNKNOWN_TAG", + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + expect(body.data.skipped.catalogueKeys).toEqual([ + { catalogue: "moodTag", key: "rt_retired_tag", links: 1 }, + ]); + expect(body.data.skipped.links).toBe(1); + + // The entry itself came back whole. One key that has nowhere to attach + // costs the link, never the record. + const restored = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-unknown-tag" }, + }); + expect(restored.score).toBe(3); + const links = await prisma.moodEntryTagLink.findMany({ + where: { moodEntryId: restored.id }, + select: { moodTag: { select: { key: true } } }, + }); + expect(links).toEqual([{ moodTag: { key: "rt_headache" } }]); + }); +});