diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index ceb9557f5..a4add73d3 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,21 +10,21 @@ permissions: contents: read jobs: - # The suite outgrew a single runner: 128 files took ~13 minutes when the - # 25-minute guard was written, and the v1.37 sharing work took it to 173 - # files and past 25 minutes of wall clock, so every run died at the timeout - # with every test to that point green. The guard's own rule applies — split, - # don't raise — so the files run as two vitest shards. Each shard boots its - # own Postgres testcontainer; the timeout stays a hang detector, sized so a - # healthy half-suite clears it on a slow runner. + # The suite outgrew a single runner, and then a half-suite outgrew a slow + # one: two shards cleared in ~14 minutes on a quiet day, but with six pull + # requests running side by side the larger shard hit the same 25-minute + # guard the split was meant to escape. Three shards keep the biggest slice + # around 60 files, which clears the guard with real headroom even on the + # slowest runner observed. Each shard boots its own Postgres testcontainer; + # the timeout stays a hang detector, not a performance budget. shard: runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false matrix: - shard: [1, 2] - name: integration shard ${{ matrix.shard }}/2 + shard: [1, 2, 3] + name: integration shard ${{ matrix.shard }}/3 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -44,7 +44,7 @@ jobs: # vitest a stray positional argument and the shard flag never applies — # both "shards" then run the full suite. Proven by a paired local run # before this workflow shipped. - - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/2 + - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/3 env: # The integration suite boots a Postgres testcontainer and points # Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`. diff --git a/CHANGELOG.md b/CHANGELOG.md index f94ce3040..540c25071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,19 @@ reliably the one that ran. It is selected by name now, and a copy that never arrived fails the build instead of quietly leaving another one in its place. +### Fixed + +- Mood entries now survive a restore whole. The disaster-recovery backup was + carrying only the rated factors of an entry, so every tag ticked present or + absent was missing from the file, and it was dropping the note, the entry's + own timezone and the sync counter as well. A restored entry came back without + its text, read its day boundaries under the old Berlin assumption whatever + zone it had been logged in, and could lose the next sync round to a paired + phone still holding a higher counter. The backup carries every column now, + both halves of the tag taxonomy come back, and a structural check fails the + build if a future column goes missing the same way. Backup files written + before this restore exactly as they did. + ## [1.37.0] — 2026-08-07 ### Added diff --git a/messages/de.json b/messages/de.json index 7468d4f41..455bad74f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Zyklussymptom", "restoreSkippedIllnessSymptom": "Krankheitssymptom", "restoreSkippedMoodFactor": "Stimmungsfaktor", + "restoreSkippedMoodTag": "Stimmungs-Tag", "docsLink": "Dokumentation" }, "danger-zone": { diff --git a/messages/en.json b/messages/en.json index 1b9ce1268..2041766c0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Cycle symptom", "restoreSkippedIllnessSymptom": "Illness symptom", "restoreSkippedMoodFactor": "Mood factor", + "restoreSkippedMoodTag": "Mood tag", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/es.json b/messages/es.json index 35193f614..bafa72e6e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Síntoma del ciclo", "restoreSkippedIllnessSymptom": "Síntoma de enfermedad", "restoreSkippedMoodFactor": "Factor de ánimo", + "restoreSkippedMoodTag": "Etiqueta de ánimo", "docsLink": "Documentación" }, "danger-zone": { diff --git a/messages/fr.json b/messages/fr.json index b3df467d1..5324f563a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Symptôme de cycle", "restoreSkippedIllnessSymptom": "Symptôme de maladie", "restoreSkippedMoodFactor": "Facteur d'humeur", + "restoreSkippedMoodTag": "Étiquette d'humeur", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/it.json b/messages/it.json index 7f96ce248..38af4fc16 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Sintomo del ciclo", "restoreSkippedIllnessSymptom": "Sintomo di malattia", "restoreSkippedMoodFactor": "Fattore dell'umore", + "restoreSkippedMoodTag": "Etichetta dell'umore", "docsLink": "Documentazione" }, "danger-zone": { diff --git a/messages/pl.json b/messages/pl.json index 2da87ee8b..702ad81c5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Objaw cyklu", "restoreSkippedIllnessSymptom": "Objaw choroby", "restoreSkippedMoodFactor": "Czynnik nastroju", + "restoreSkippedMoodTag": "Tag nastroju", "docsLink": "Dokumentacja" }, "danger-zone": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92dc14bec..4f5efaea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: dompurify@<=3.4.10: ^3.4.11 postcss@<8.5.10: ^8.5.10 + nanoid@<3.3.17: ^3.3.17 '@hono/node-server@<1.19.13': ^1.19.13 hono@<4.12.25: ^4.12.25 picomatch@<4.0.4: ^4.0.4 @@ -5556,13 +5557,8 @@ packages: nan@2.27.0: resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -12636,9 +12632,7 @@ snapshots: nan@2.27.0: optional: true - nanoid@3.3.12: {} - - nanoid@3.3.16: {} + nanoid@3.3.17: {} napi-postinstall@0.3.4: {} @@ -13045,19 +13039,19 @@ snapshots: postcss@8.5.14: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.20: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.25: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c7fabca7c..3fa8ec2d3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ overrides: "dompurify@<=3.4.10": "^3.4.11" "postcss@<8.5.10": "^8.5.10" + "nanoid@<3.3.17": "^3.3.17" "@hono/node-server@<1.19.13": "^1.19.13" "hono@<4.12.25": "^4.12.25" "picomatch@<4.0.4": "^4.0.4" diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts new file mode 100644 index 000000000..ef483325d --- /dev/null +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -0,0 +1,294 @@ +/** + * Pair guard binding `prisma/schema.prisma`'s `MoodEntry` and + * `MoodEntryTagLink` models to the disaster-recovery backup selects. + * + * The sibling of `measurement-backup-completeness.test.ts`, written as its own + * file rather than as a second describe inside it, because that guard's own + * note asks for model-by-model growth and one file per model keeps every + * failure message about one model. + * + * The failure it exists to catch, stated by the defect that prompted it: the + * mood payload carried `id, date, mood, score, tags, source, loggedAt, + * externalId, deletedAt, factors` and nothing else. The free-text note, the + * row's timezone and the last-writer-wins counter were selected by the query + * and written to no payload, so a restored entry came back without its text, + * read its day boundaries under the legacy Europe/Berlin assumption, and lost + * the next sync round to a paired device still holding a higher counter. The + * backup validation schema is `.passthrough()`, so nothing complained at parse + * time either. Nothing here was a wrong value; every one of them was silence. + * + * Both ends of the comparison are literals — a Prisma model block and a named + * select object — so the matcher is exact. Every matcher also asserts a + * non-zero match count: an empty match set fails rather than passing quietly, + * which is how the bearer-scope guard stayed green for months while matching + * nothing at all. + * + * Mutation check: comment out `tz: true` in `MOOD_ENTRY_BACKUP_SELECT` and + * this goes red naming that column; add a scalar to the model without adding + * it to the select and it goes red naming the new one; rename the mapper's + * `moodEntry` parameter and the serialization assertion goes red on all of + * them at once. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, it, expect } from "vitest"; + +const ROOT = process.cwd(); +const SCHEMA = join(ROOT, "prisma", "schema.prisma"); +const BACKUP_BUILDER = join( + ROOT, + "src", + "lib", + "export", + "full-backup-payload.ts", +); + +/** + * Columns the backup is correct to omit, each with the reason. The reasons are + * length-checked below: an exclusion is a claim, and a claim needs saying. + */ +const EXCLUDED_MOOD_ENTRY_COLUMNS: Record = { + userId: "user-scoped backup; owner is re-derived from the restoring account", +}; + +const EXCLUDED_TAG_LINK_COLUMNS: Record = { + moodEntryId: "re-bound to the restored parent row's id, never carried", + moodTagId: + "the link travels by the tag's key; a source-instance id need not exist in the target catalogue", + createdAt: + "the link's own row stamp; the entry carries the moment the person logged it, and a key-only BINARY tag has nowhere to put a second one", +}; + +function modelBlock(schema: string, model: string): string { + const start = schema.indexOf(`model ${model} {`); + expect(start, `model ${model} not found`).toBeGreaterThan(-1); + const end = schema.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + return schema.slice(start, end); +} + +/** Names of every `model` block, so relation fields can be told apart. */ +function declaredModels(schema: string): Set { + const out = new Set(); + for (const m of schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)) { + out.add(m[1]); + } + return out; +} + +/** + * Scalar (column-backed) fields of a model block: everything that is not a + * relation to another model. Enums and native scalars both map to columns and + * therefore both belong in a backup. + */ +function scalarFields(block: string, models: Set): string[] { + const fields: string[] = []; + for (const rawLine of block.split("\n").slice(1)) { + const line = rawLine.trim(); + if (!line || line.startsWith("//") || line.startsWith("@@")) continue; + const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?(\?)?/.exec(line); + if (!m) continue; + const [, name, type, isList] = m; + if (isList) continue; + if (models.has(type)) continue; + if (line.includes("@relation")) continue; + fields.push(name); + } + return fields; +} + +/** Keys of a named `const = {` select literal. */ +function selectFields(source: string, constant: string): string[] { + const start = source.indexOf(`const ${constant} = {`); + expect(start, `${constant} not found`).toBeGreaterThan(-1); + const end = source.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + const body = source.slice(start, end); + return [...body.matchAll(/^ {2}([a-zA-Z_$][\w$]*): true,$/gm)].map( + (m) => m[1], + ); +} + +/** + * The builder with its comment lines removed. + * + * Every matcher below runs against this rather than against the raw file, + * because prose describing the defect reads exactly like the defect: the first + * draft of this guard went red on its own explanation of the filter it exists + * to forbid. Whole comment lines only — a trailing comment after code is left + * alone, which is safe here because the only risk it carries is a matcher + * accepting a column named in prose, and the select comparison beside it is + * literal either way. + */ +function stripCommentLines(source: string): string { + return source + .split("\n") + .filter((line) => { + const trimmed = line.trim(); + return !( + trimmed.startsWith("//") || + trimmed.startsWith("/*") || + trimmed.startsWith("*") + ); + }) + .join("\n"); +} + +/** + * Columns either serialization arm actually reads off the mapped row. + * + * The capture is the RIGHT-hand side — the column — not the payload key, which + * matters here in a way it did not for `Measurement`: the mood payload renames + * `moodLoggedAt` to `loggedAt` on the wire, so a left-hand matcher would report + * the column as dropped while the value rides perfectly well. + * + * The two arms share one restorable wire format, so this is a union and + * therefore a weaker claim than the select comparison: it catches a column that + * is selected and then written to neither payload, not one carried by only a + * single arm. + */ +function serialisedFields(source: string, variable: string): Set { + const matches = [ + ...source.matchAll( + new RegExp(`[:(,]\\s*${variable}\\.([a-zA-Z_$][\\w$]*)`, "g"), + ), + ]; + expect( + matches.length, + `no \`${variable}.\` reads found — the mapper parameter was renamed and this matcher now proves nothing`, + ).toBeGreaterThan(0); + return new Set(matches.map((m) => m[1])); +} + +describe("mood backup completeness", () => { + const schema = readFileSync(SCHEMA, "utf8"); + const source = stripCommentLines(readFileSync(BACKUP_BUILDER, "utf8")); + const models = declaredModels(schema); + + const entryColumns = scalarFields(modelBlock(schema, "MoodEntry"), models); + const entrySelected = selectFields(source, "MOOD_ENTRY_BACKUP_SELECT"); + const linkColumns = scalarFields( + modelBlock(schema, "MoodEntryTagLink"), + models, + ); + const linkSelected = selectFields(source, "MOOD_ENTRY_TAG_LINK_SELECT"); + + it("parses both ends into a plausible shape", () => { + expect(models.size).toBeGreaterThan(50); + expect(entryColumns.length).toBeGreaterThan(10); + expect(entryColumns).toContain("noteEncrypted"); + expect(entryColumns).toContain("syncVersion"); + expect(entryColumns).not.toContain("user"); + expect(entryColumns).not.toContain("tagLinks"); + expect(entrySelected.length).toBeGreaterThan(10); + + expect(linkColumns).toEqual( + expect.arrayContaining([ + "moodEntryId", + "moodTagId", + "createdAt", + "rating", + ]), + ); + expect(linkColumns).not.toContain("moodEntry"); + expect(linkColumns).not.toContain("moodTag"); + expect(linkSelected.length).toBeGreaterThan(0); + }); + + it("the backup select covers every MoodEntry column", () => { + const missing = entryColumns.filter( + (c) => !entrySelected.includes(c) && !(c in EXCLUDED_MOOD_ENTRY_COLUMNS), + ); + + if (missing.length > 0) { + const report = missing.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `MOOD_ENTRY_BACKUP_SELECT omits ${missing.length} column(s) of the MoodEntry model:\n${report}\n\n` + + "A disaster-recovery restore brings these back at their column " + + "default. Add each to the select and to both serialization arms, " + + "extend `moodEntrySchema` and the restore write — or add it to " + + "EXCLUDED_MOOD_ENTRY_COLUMNS with the reason the backup is correct " + + "to drop it.", + ); + } + }); + + it("every selected MoodEntry column reaches a serialized payload", () => { + const emitted = serialisedFields(source, "moodEntry"); + const dropped = entrySelected.filter((c) => !emitted.has(c)); + + if (dropped.length > 0) { + const report = dropped.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `${dropped.length} selected MoodEntry column(s) reach neither serialization arm:\n${report}\n\n` + + "The query pays for them and no payload carries them, so a restore " + + "cannot put them back. Emit each from the disaster-recovery arm " + + "(and, where it is readable and not a tombstone, from the portable " + + "arm too).", + ); + } + }); + + it("the tag-link select covers every MoodEntryTagLink column", () => { + const missing = linkColumns.filter( + (c) => !linkSelected.includes(c) && !(c in EXCLUDED_TAG_LINK_COLUMNS), + ); + expect( + missing, + `MOOD_ENTRY_TAG_LINK_SELECT omits MoodEntryTagLink column(s): ${missing.join(", ")}`, + ).toEqual([]); + }); + + it("every selected tag-link column reaches a serialized payload", () => { + const emitted = serialisedFields(source, "tagLink"); + const dropped = linkSelected.filter((c) => !emitted.has(c)); + expect( + dropped, + `selected but serialized by neither arm: ${dropped.join(", ")}`, + ).toEqual([]); + }); + + it("both structured-tag arms are carried, not just the rated one", () => { + // The defect this file was written for: the tag-link read carried a + // filter, and a BINARY link's rating is NULL by definition, so every + // present/absent tag — the kind most people pick — was excluded from the + // file and could not come back from it. The claim is therefore about the + // read itself: it takes the links whole, unfiltered. + // + // Asserted on booleans rather than on the file contents so a failure reads + // as one line instead of printing the whole builder back. + const tagLinkRead = /tagLinks:\s*\{([\s\S]*?)\}/.exec(source); + expect( + tagLinkRead, + "no `tagLinks:` read found in the backup builder — this matcher proves nothing", + ).not.toBeNull(); + expect( + /where/.test(tagLinkRead![1]), + "the tag-link read filters again; a filter here is what dropped every BINARY link from the backup", + ).toBe(false); + expect( + source.includes("structuredTags"), + "no `structuredTags` key in the payload — the BINARY links have no carrier", + ).toBe(true); + }); + + it("the exclusion lists carry no stale entry", () => { + for (const [column, reason] of Object.entries( + EXCLUDED_MOOD_ENTRY_COLUMNS, + )) { + expect( + entryColumns, + `${column} is excluded but is no longer a MoodEntry column`, + ).toContain(column); + expect(reason.length).toBeGreaterThan(20); + } + for (const [column, reason] of Object.entries(EXCLUDED_TAG_LINK_COLUMNS)) { + expect( + linkColumns, + `${column} is excluded but is no longer a MoodEntryTagLink column`, + ).toContain(column); + expect(reason.length).toBeGreaterThan(20); + } + }); +}); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3c22417f3..8ede74cac 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -684,6 +684,10 @@ const handler = apiHandler( entry.factors.map((factor) => factor.key), ); const factorKeys = [...new Set(referencedFactorKeys)]; + const referencedTagKeys = payload.moodEntries.flatMap( + (entry) => entry.structuredTags, + ); + const tagKeys = [...new Set(referencedTagKeys)]; // Re-create the account's own tag definitions first. The lookup // below used to ask only for the seeded catalogue (`userId: null`), // so a single custom rated tag made the whole restore throw @@ -740,6 +744,30 @@ const handler = apiHandler( referencedFactorKeys, ); + // The BINARY half of the same taxonomy, looked up the same way and + // reported the same way. It gets its own family because an + // operator reading the report needs to know whether a rated + // factor's score or a ticked tag went missing; they are different + // losses on the same entry. + const tagRows = + tagKeys.length === 0 + ? [] + : await tx.moodTag.findMany({ + where: { + key: { in: tagKeys }, + kind: "BINARY", + OR: [{ userId: null }, { userId: ownerId }], + }, + select: { id: true, key: true }, + }); + const tagByKey = new Map(tagRows.map((tag) => [tag.key, tag.id])); + recordUnknownKeys( + skips, + "moodTag", + tagKeys.filter((key) => !tagByKey.has(key)), + referencedTagKeys, + ); + for (const entry of payload.moodEntries) { const moodLoggedAt = new Date(entry.loggedAt); const restoredData = { @@ -747,10 +775,44 @@ const handler = apiHandler( mood: entry.mood, score: entry.score, tags: entry.tags ?? null, + // The note comes back the way every other dual-column note + // does: ciphertext verbatim when the file carries it, and a + // portable file's plaintext re-encrypted on the way in, with + // the legacy column left null either way. + note: null, + noteEncrypted: + entry.noteEncrypted == null + ? encryptNote(entry.note ?? null) + : decodeEncryptedBytes(entry.noteEncrypted), source: entry.source ?? "MOODLOG", externalId: entry.externalId ?? null, moodLoggedAt, + // The zone the `date` string is anchored to. A file written + // before this rode the payload carries nothing, and NULL is + // what such a row meant anyway: the legacy Europe/Berlin + // reading. So an old file keeps its old day boundaries and a + // new one keeps the zone the person actually logged in. + tz: entry.tz ?? null, + // Mood reconciles last-writer-wins by `syncVersion`. Restoring + // at the schema default would hand the next sync round to any + // paired device still holding a higher number, and the device + // would overwrite the recovered row with what it had — a + // restore that loses to the client is worse than one that + // fails. An old file carries nothing here and keeps the + // default, which is the value those rows were written with. + ...(entry.syncVersion === undefined + ? {} + : { syncVersion: entry.syncVersion }), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + ...(entry.syncedAt + ? { syncedAt: new Date(entry.syncedAt) } + : {}), + ...(entry.createdAt + ? { createdAt: new Date(entry.createdAt) } + : {}), + ...(entry.updatedAt + ? { updatedAt: new Date(entry.updatedAt) } + : {}), }; const createData = { userId: ownerId, ...restoredData }; const restored = entry.id @@ -798,8 +860,19 @@ const handler = apiHandler( ] : []; }); - if (factorLinks.length > 0) { - await tx.moodEntryTagLink.createMany({ data: factorLinks }); + // The other arm. A BINARY link carries no score — the presence + // of the row is the signal — so `rating` is NULL by definition, + // which is exactly what the backup's old tag-link filter took to + // mean "nothing here". + const tagLinks = entry.structuredTags.flatMap((key) => { + const moodTagId = tagByKey.get(key); + return moodTagId + ? [{ moodEntryId: restored.id, moodTagId, rating: null }] + : []; + }); + const links = [...factorLinks, ...tagLinks]; + if (links.length > 0) { + await tx.moodEntryTagLink.createMany({ data: links }); } } } diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 2df37779f..76c3acede 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -194,6 +194,9 @@ function catalogueLabel( if (catalogue === "illnessSymptom") { return t("admin.section.backups.restoreSkippedIllnessSymptom"); } + if (catalogue === "moodTag") { + return t("admin.section.backups.restoreSkippedMoodTag"); + } return t("admin.section.backups.restoreSkippedMoodFactor"); } diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 4d76bc57e..e55bf7b69 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -38,6 +38,8 @@ import { restoreProfileData } from "../profile-backup"; const deletedAt = new Date("2026-07-19T12:00:00.000Z"); const measurementNote = encryptToBytes("canonical measurement note"); const sideEffectNote = encryptToBytes("nausea after the evening dose"); +const moodNotePlaintext = "quiet evening, slept badly"; +const moodNote = encryptToBytes(moodNotePlaintext); const appSettings = { id: "singleton", @@ -193,11 +195,27 @@ function makePrisma() { mood: "OKAY", score: 3, tags: null, + note: null, + noteEncrypted: moodNote, source: "MOODLOG", externalId: "moodlog-42", moodLoggedAt: new Date("2026-07-19T08:00:00.000Z"), + tz: "America/New_York", + syncedAt: new Date("2026-07-19T08:00:01.000Z"), + syncVersion: 3, deletedAt, - tagLinks: [{ rating: 4, moodTag: { key: "sleep_quality" } }], + createdAt: new Date("2026-07-19T08:00:00.000Z"), + updatedAt: new Date("2026-07-19T09:00:00.000Z"), + tagLinks: [ + { + rating: 4, + moodTag: { key: "sleep_quality", kind: "RATED" }, + }, + { + rating: null, + moodTag: { key: "headache", kind: "BINARY" }, + }, + ], }, ]), }, @@ -409,7 +427,13 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { expect(prisma.moodEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: "user-1" }, - include: expect.any(Object), + select: expect.objectContaining({ + id: true, + noteEncrypted: true, + tz: true, + syncVersion: true, + deletedAt: true, + }), }), ); expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith( @@ -485,7 +509,16 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { id: "mood-tombstone", externalId: "moodlog-42", deletedAt: deletedAt.toISOString(), + tz: "America/New_York", + syncedAt: "2026-07-19T08:00:01.000Z", + syncVersion: 3, + createdAt: "2026-07-19T08:00:00.000Z", + updatedAt: "2026-07-19T09:00:00.000Z", + note: null, + noteEncrypted: Buffer.from(moodNote).toString("base64"), factors: [{ key: "sleep_quality", rating: 4 }], + // The BINARY link the old `rating: { not: null }` filter excluded. + structuredTags: ["headache"], }, ], nutrientDays: [ @@ -1148,3 +1181,79 @@ describe("buildFullBackupPayload — profile and custom metrics", () => { expect(counts.intradayProfiles).toBe(1); }); }); + +describe("buildFullBackupPayload — mood arms", () => { + it("carries the ciphertext and the whole row on the disaster-recovery arm", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const [entry] = (payload as { moodEntries: Array> }) + .moodEntries; + expect(entry).toEqual( + expect.objectContaining({ + id: "mood-tombstone", + note: null, + noteEncrypted: Buffer.from(moodNote).toString("base64"), + tz: "America/New_York", + syncedAt: "2026-07-19T08:00:01.000Z", + syncVersion: 3, + createdAt: "2026-07-19T08:00:00.000Z", + updatedAt: "2026-07-19T09:00:00.000Z", + deletedAt: deletedAt.toISOString(), + factors: [{ key: "sleep_quality", rating: 4 }], + structuredTags: ["headache"], + }), + ); + }); + + it("carries the readable note and no ciphertext on the portable arm", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "portable-export", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const [entry] = (payload as { moodEntries: Array> }) + .moodEntries; + // Same split the measurement arm makes: the readable artefact carries the + // decrypted text and nothing an operator could not read, and the counters + // that only mean something to this instance stay behind. + expect(entry).toEqual( + expect.objectContaining({ + note: moodNotePlaintext, + tz: "America/New_York", + structuredTags: ["headache"], + }), + ); + expect(entry).not.toHaveProperty("noteEncrypted"); + expect(entry).not.toHaveProperty("syncVersion"); + expect(entry).not.toHaveProperty("syncedAt"); + expect(JSON.stringify(entry)).not.toContain( + Buffer.from(moodNote).toString("base64"), + ); + // The tag-link read is unfiltered on both arms; a BINARY link is the one + // most people have and it was the one the file never carried. + expect(prisma.moodEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.objectContaining({ + tagLinks: { select: expect.any(Object) }, + }), + }), + ); + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 57ef86451..9f4506de5 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -103,6 +103,46 @@ const MEASUREMENT_BACKUP_SELECT = { updatedAt: true, } as const satisfies Prisma.MeasurementSelect; +/** + * Every restorable `MoodEntry` scalar. `userId` is absent for the same reason + * it is absent above: the backup is user-scoped and the owner is re-derived + * from the restoring account. + * + * Named rather than inlined so `mood-backup-completeness.test.ts` can compare + * it field-by-field against the model in `prisma/schema.prisma`, exactly as the + * measurement guard does for the select above. + */ +const MOOD_ENTRY_BACKUP_SELECT = { + id: true, + date: true, + mood: true, + score: true, + tags: true, + note: true, + noteEncrypted: true, + source: true, + externalId: true, + moodLoggedAt: true, + tz: true, + syncedAt: true, + syncVersion: true, + deletedAt: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.MoodEntrySelect; + +/** + * A structured-tag link, as it rides the wire. The link is carried by the + * tag's KEY, not by its id: a restore resolves the key against the target + * instance's catalogue, where the source instance's row id may not exist at + * all. `kind` comes along so the mapper can split RATED factors from BINARY + * tags without a second query. + */ +const MOOD_ENTRY_TAG_LINK_SELECT = { + rating: true, + moodTag: { select: { key: true, kind: true } }, +} as const satisfies Prisma.MoodEntryTagLinkSelect; + /** * Build the canonical full-backup payload for `userId`. Portable exports omit * tombstones and document ciphertext; disaster-recovery payloads preserve @@ -207,14 +247,13 @@ export async function buildFullBackupPayload( prisma.moodEntry.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { moodLoggedAt: "desc" }, - include: { - tagLinks: { - where: { rating: { not: null } }, - select: { - rating: true, - moodTag: { select: { key: true } }, - }, - }, + select: { + ...MOOD_ENTRY_BACKUP_SELECT, + // Every link, not only the rated ones. This read used to filter + // `rating: { not: null }`, and a BINARY link carries a NULL rating by + // definition — so the present/absent tags, the kind most people pick, + // were absent from the file and could not come back from it. + tagLinks: { select: MOOD_ENTRY_TAG_LINK_SELECT }, }, }), // The account's OWN mood tags. The seeded catalogue is reference data every @@ -394,21 +433,66 @@ export async function buildFullBackupPayload( skipped: e.skipped, source: e.source, })), - moodEntries: moodEntries.map((e) => ({ - id: e.id, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags, - source: e.source, - loggedAt: e.moodLoggedAt.toISOString(), - externalId: e.externalId, - deletedAt: e.deletedAt?.toISOString() ?? null, - factors: e.tagLinks.map((link) => ({ - key: link.moodTag.key, - rating: link.rating!, - })), - })), + // The parameter is spelled out rather than the `e` every other mapper here + // uses, and deliberately: `mood-backup-completeness.test.ts` proves a + // selected column reaches a payload by matching `: moodEntry.`. A matcher + // on `: e.` would also match the intake-event mapper above it and could + // therefore never fail. + moodEntries: moodEntries.map((moodEntry) => { + // The two link arms partition the entry's links exhaustively: a link is + // a rated factor when its tag is RATED and it actually carries a score, + // and a structured tag otherwise. Total on purpose — a malformed link + // (a RATED tag whose rating went missing) rides as a tag key and comes + // back NAMED as unresolvable on restore, rather than falling between the + // two arms and disappearing the way every BINARY link used to. + const isRatedFactor = (link: (typeof moodEntry.tagLinks)[number]) => + link.moodTag.kind === "RATED" && link.rating !== null; + + const factors = moodEntry.tagLinks + .filter(isRatedFactor) + .map((tagLink) => ({ + key: tagLink.moodTag.key, + rating: tagLink.rating as number, + })); + // BINARY link keys. Their own array rather than a nullable rating inside + // `factors`: the restore resolves `factors` against `kind: "RATED"` and + // reports what it cannot resolve, so a rating-less entry in that array + // would collide with both. A separate key leaves every backup file + // written before this parsing and restoring exactly as it did. + const structuredTags = moodEntry.tagLinks + .filter((link) => !isRatedFactor(link)) + .map((tagLink) => tagLink.moodTag.key); + + return { + ...(disasterRecovery + ? { + note: moodEntry.note, + noteEncrypted: moodEntry.noteEncrypted + ? Buffer.from(moodEntry.noteEncrypted).toString("base64") + : null, + syncedAt: moodEntry.syncedAt.toISOString(), + syncVersion: moodEntry.syncVersion, + createdAt: moodEntry.createdAt.toISOString(), + updatedAt: moodEntry.updatedAt.toISOString(), + } + : { note: readNote(moodEntry.noteEncrypted, moodEntry.note) }), + id: moodEntry.id, + date: moodEntry.date, + mood: moodEntry.mood, + score: moodEntry.score, + tags: moodEntry.tags, + source: moodEntry.source, + loggedAt: moodEntry.moodLoggedAt.toISOString(), + externalId: moodEntry.externalId, + // The zone the `date` string is anchored to. Without it a restored row + // falls back to the legacy Europe/Berlin reading and an account that + // logged elsewhere gets its day boundaries quietly moved. + tz: moodEntry.tz, + deletedAt: moodEntry.deletedAt?.toISOString() ?? null, + factors, + structuredTags, + }; + }), customMoodTags: customMoodTags.map((tag) => ({ ...(disasterRecovery ? { id: tag.id } : {}), key: tag.key, diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index e57756edd..326ea093b 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -1,8 +1,9 @@ /** * What a restore could not put back, and how much of it there was. * - * Three of the restore's lookups resolve a key against a SEEDED CATALOGUE — - * cycle symptoms, illness symptoms, mood factors. The catalogue is reference + * Four of the restore's lookups resolve a key against a SEEDED CATALOGUE — + * cycle symptoms, illness symptoms, rated mood factors and the mood tags a + * person ticks present or absent. The catalogue is reference * data the instance owns, not data the file carries, so it legitimately drifts * between the day a backup is written and the day it is read: a key gets * renamed, a symptom is retired, the file is a year old, the instance is three @@ -28,7 +29,8 @@ */ /** Which seeded catalogue a key failed to resolve against. */ -export type SkippedCatalogue = "cycleSymptom" | "illnessSymptom" | "moodFactor"; +export type SkippedCatalogue = + "cycleSymptom" | "illnessSymptom" | "moodFactor" | "moodTag"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b4afaf650..3f1a981e1 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -255,11 +255,29 @@ const moodEntrySchema = z mood: z.string().min(1), score: z.number().int().min(0).max(10), tags: moodEntryTagsSchema, + // The free text, in both storage shapes. A portable export carries the + // decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus + // whatever legacy plaintext the row still holds. Same pair, and same + // handling on restore, as `Measurement.notes` / `notesEncrypted`. + note: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), source: z.string().min(1).optional(), externalId: z.string().nullable().optional(), loggedAt: isoDateTime, + // The IANA zone the `date` string is anchored to. Absent means the legacy + // Europe/Berlin reading, which is what a row written before v1.4.25 means + // by a NULL here — so an old file keeps its old meaning exactly. + tz: z.string().nullable().optional(), + syncedAt: isoDateTime.optional(), + syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), factors: z.array(moodFactorSchema).default([]), + // BINARY structured-tag keys. Separate from `factors`, which keeps its + // RATED-only meaning, so every file written before this one parses + // unchanged and restores identically: absent reads as none. + structuredTags: z.array(z.string().min(1)).default([]), }) .passthrough(); diff --git a/tests/integration/cycle-flow-opens-cycle.test.ts b/tests/integration/cycle-flow-opens-cycle.test.ts index 5e81fda56..7a9fe7d84 100644 --- a/tests/integration/cycle-flow-opens-cycle.test.ts +++ b/tests/integration/cycle-flow-opens-cycle.test.ts @@ -70,6 +70,14 @@ beforeEach(async () => { username: "cycle-flow", email: "cycle-flow@example.test", gender: "FEMALE", + // The suite runs under TZ=UTC and computes its relative day keys with + // local Date arithmetic, i.e. in UTC. The route resolves "today" in the + // USER's zone and falls back to Europe/Berlin when none is set - one + // hour past 22:00 UTC the two clocks disagree about the date and every + // day-of-cycle assertion is off by one. Pinning the fixture zone to UTC + // makes the test's clock and the route's clock the same clock at any + // hour of the day. + timezone: "UTC", }, }); await loginAs(USER_ID); diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts index 8dc05e92b..d75c8822f 100644 --- a/tests/integration/cycle-routes.test.ts +++ b/tests/integration/cycle-routes.test.ts @@ -69,6 +69,14 @@ beforeEach(async () => { username: "cycle-female", email: "cycle-female@example.test", gender: "FEMALE", + // The suite runs under TZ=UTC and computes its relative day keys with + // local Date arithmetic, i.e. in UTC. The route resolves "today" in the + // USER's zone and falls back to Europe/Berlin when none is set - one + // hour past 22:00 UTC the two clocks disagree about the date and every + // day-of-cycle assertion is off by one. Pinning the fixture zone to UTC + // makes the test's clock and the route's clock the same clock at any + // hour of the day. + timezone: "UTC", }, }); await prisma.user.create({ @@ -77,6 +85,7 @@ beforeEach(async () => { username: "cycle-male", email: "cycle-male@example.test", gender: "MALE", + timezone: "UTC", }, }); }); 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" } }]); + }); +});