From 9c4235b66dc4a5af5a97e6c74143738805219675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 09:04:36 +0200 Subject: [PATCH 1/2] fix(backup): a retired catalogue key must not cost the whole file Three of the restore's lookups resolve a key against a seeded catalogue: cycle symptoms, illness symptoms, mood factors. Each threw on a key it could not resolve, inside the one transaction that wipes and rebuilds the account. So a single renamed symptom rolled everything back and answered 500. No measurements, no doses, no notes, nothing. A year-old backup read by a newer instance is the ordinary case for a backup, and the catalogue drifts between those two dates by design. The unresolvable link is dropped and named now. What it costs is one association between a row and a symptom; the day-log, the mood entry, the flow, the temperature and the encrypted note all come back. The count and the exact keys ride the response, the audit row and the wide event, and the admin surface shows them in a panel that stays put instead of a toast that leaves. A quiet drop would be the older failure of this same seam wearing a success message. A dangling `cycleId` or a dose recorded against a medication the file never carried still throws. Those name rows the file was supposed to hold, so a missing one means the file contradicts itself. A catalogue key names a row the instance owns, which is why it gets the other answer. `IllnessSymptom` carries no owner column at all, so a key that will not resolve there can only ever be drift; the file was never able to supply it. The restore does not mint the missing catalogue row back. That would preserve the link at the price of reference data colliding with whatever the next migration seeds under that key, and a label nothing can translate. --- messages/de.json | 7 + messages/en.json | 7 + messages/es.json | 7 + messages/fr.json | 7 + messages/it.json | 7 + messages/pl.json | 7 + .../api/admin/backups/[id]/restore/route.ts | 151 +++++-- src/components/admin/backups-section.tsx | 108 ++++- src/lib/cycle/backup.ts | 52 ++- src/lib/export/restore-skips.ts | 89 ++++ .../admin-backups-canonical-roundtrip.test.ts | 25 +- ...ckup-unknown-catalogue-key-restore.test.ts | 419 ++++++++++++++++++ tests/integration/cycle-import-export.test.ts | 8 +- 13 files changed, 824 insertions(+), 70 deletions(-) create mode 100644 src/lib/export/restore-skips.ts create mode 100644 tests/integration/backup-unknown-catalogue-key-restore.test.ts diff --git a/messages/de.json b/messages/de.json index a5a24f4da..1d8f1da72 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Wiederherstellung läuft…", "restoreSuccess": "Wiederherstellung abgeschlossen", "restoreFailed": "Wiederherstellung fehlgeschlagen", + "restoreSkippedToast": "Wiederherstellung abgeschlossen, {links} Symptomverknüpfungen konnten nicht zurückgeschrieben werden", + "restoreSkippedTitle": "{links} Verknüpfungen wurden nicht wiederhergestellt", + "restoreSkippedDescription": "Die Datei verweist auf Einträge, die diese Installation nicht kennt. Alles andere wurde wiederhergestellt. Dieselbe Liste steht im Audit-Log.", + "restoreSkippedLinks": "{count} Verknüpfungen", + "restoreSkippedCycleSymptom": "Zyklussymptom", + "restoreSkippedIllnessSymptom": "Krankheitssymptom", + "restoreSkippedMoodFactor": "Stimmungsfaktor", "docsLink": "Dokumentation" }, "danger-zone": { diff --git a/messages/en.json b/messages/en.json index a63fa34f2..b44d7e40b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Restoring…", "restoreSuccess": "Restore complete", "restoreFailed": "Restore failed", + "restoreSkippedToast": "Restore complete, {links} symptom links could not be put back", + "restoreSkippedTitle": "{links} links were not restored", + "restoreSkippedDescription": "The file references entries this installation does not know. Everything else was restored. The same list is in the audit log.", + "restoreSkippedLinks": "{count} links", + "restoreSkippedCycleSymptom": "Cycle symptom", + "restoreSkippedIllnessSymptom": "Illness symptom", + "restoreSkippedMoodFactor": "Mood factor", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/es.json b/messages/es.json index 30e8f2fdf..b3f0f4338 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Restaurando...", "restoreSuccess": "Restauración completada", "restoreFailed": "La restauración falló", + "restoreSkippedToast": "Restauración completada, {links} vínculos de síntomas no se pudieron recuperar", + "restoreSkippedTitle": "{links} vínculos no se restauraron", + "restoreSkippedDescription": "El archivo hace referencia a entradas que esta instalación no conoce. Todo lo demás se restauró. La misma lista está en el registro de auditoría.", + "restoreSkippedLinks": "{count} vínculos", + "restoreSkippedCycleSymptom": "Síntoma del ciclo", + "restoreSkippedIllnessSymptom": "Síntoma de enfermedad", + "restoreSkippedMoodFactor": "Factor de ánimo", "docsLink": "Documentación" }, "danger-zone": { diff --git a/messages/fr.json b/messages/fr.json index fde24ba37..160b2706a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Restauration en cours...", "restoreSuccess": "Restauration terminée", "restoreFailed": "Échec de la restauration", + "restoreSkippedToast": "Restauration terminée, {links} liens de symptômes n'ont pas pu être rétablis", + "restoreSkippedTitle": "{links} liens n'ont pas été restaurés", + "restoreSkippedDescription": "Le fichier référence des entrées que cette installation ne connaît pas. Tout le reste a été restauré. La même liste figure dans le journal d'audit.", + "restoreSkippedLinks": "{count} liens", + "restoreSkippedCycleSymptom": "Symptôme de cycle", + "restoreSkippedIllnessSymptom": "Symptôme de maladie", + "restoreSkippedMoodFactor": "Facteur d'humeur", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/it.json b/messages/it.json index fc5f686ab..8713772d7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Ripristino in corso...", "restoreSuccess": "Ripristino completato", "restoreFailed": "Ripristino non riuscito", + "restoreSkippedToast": "Ripristino completato, {links} collegamenti ai sintomi non sono stati recuperati", + "restoreSkippedTitle": "{links} collegamenti non sono stati ripristinati", + "restoreSkippedDescription": "Il file fa riferimento a voci che questa installazione non conosce. Tutto il resto è stato ripristinato. Lo stesso elenco è nel registro di audit.", + "restoreSkippedLinks": "{count} collegamenti", + "restoreSkippedCycleSymptom": "Sintomo del ciclo", + "restoreSkippedIllnessSymptom": "Sintomo di malattia", + "restoreSkippedMoodFactor": "Fattore dell'umore", "docsLink": "Documentazione" }, "danger-zone": { diff --git a/messages/pl.json b/messages/pl.json index f34d27c7c..7c9cfb017 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Przywracanie...", "restoreSuccess": "Przywracanie zakończone", "restoreFailed": "Przywracanie nie powiodło się", + "restoreSkippedToast": "Przywracanie zakończone, {links} powiązań objawów nie udało się odtworzyć", + "restoreSkippedTitle": "{links} powiązań nie zostało przywróconych", + "restoreSkippedDescription": "Plik odwołuje się do wpisów, których ta instalacja nie zna. Wszystko pozostałe zostało przywrócone. Ta sama lista znajduje się w dzienniku audytu.", + "restoreSkippedLinks": "{count} powiązań", + "restoreSkippedCycleSymptom": "Objaw cyklu", + "restoreSkippedIllnessSymptom": "Objaw choroby", + "restoreSkippedMoodFactor": "Czynnik nastroju", "docsLink": "Dokumentacja" }, "danger-zone": { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index f83ef5a35..8afd9e765 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -38,6 +38,12 @@ import { } from "@/lib/rollups/medication-compliance-rollups"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; +import { + recordUnknownKeys, + summarizeRestoreSkips, + type RestoreSkipLog, + type RestoreSkipSummary, +} from "@/lib/export/restore-skips"; import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; @@ -48,6 +54,14 @@ export const dynamic = "force-dynamic"; interface RestoreResponse { restored: true; summary: BackupSummary; + /** + * What the file carried that this instance could not resolve, named. + * + * `links: 0` with an empty list is the normal answer and says so: nothing + * was dropped. Anything else has to reach the operator's screen, because a + * restore that quietly loses links looks exactly like one that did not. + */ + skipped: RestoreSkipSummary; cleared: { measurements: number; medications: number; @@ -276,10 +290,18 @@ const handler = apiHandler( ); } - let cleared: RestoreResponse["cleared"]; + let outcome: { + cleared: RestoreResponse["cleared"]; + skipped: RestoreSkipSummary; + }; try { - cleared = await prisma.$transaction( + outcome = await prisma.$transaction( async (tx) => { + // Declared INSIDE the transaction so a rollback takes the report with + // it. An accumulator that outlived a failed attempt would carry drops + // that never happened into the next one. + const skips: RestoreSkipLog = []; + // Delete every serialized owner-scoped partition before rebuilding it // in the same transaction. Child rows either go first for counts or // cascade from their serialized parent. @@ -631,13 +653,10 @@ const handler = apiHandler( } if (payload.moodEntries.length > 0) { - const factorKeys = [ - ...new Set( - payload.moodEntries.flatMap((entry) => - entry.factors.map((factor) => factor.key), - ), - ), - ]; + const referencedFactorKeys = payload.moodEntries.flatMap((entry) => + entry.factors.map((factor) => factor.key), + ); + const factorKeys = [...new Set(referencedFactorKeys)]; // 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 @@ -681,12 +700,18 @@ const handler = apiHandler( const factorByKey = new Map( factorRows.map((factor) => [factor.key, factor.id]), ); - if (factorByKey.size !== factorKeys.length) { - const missing = factorKeys.filter((key) => !factorByKey.has(key)); - throw new Error( - `Unknown mood factor keys: ${missing.join(", ")}`, - ); - } + // A rated tag this instance does not know costs the entry's link to + // it, not the entry. The mood, the score, the date, the free tags — + // everything the person logged — comes back; the one rating that + // has nowhere to attach is dropped and named. Refusing the file + // instead meant a renamed seeded tag made the whole account + // unrecoverable. + recordUnknownKeys( + skips, + "moodFactor", + factorKeys.filter((key) => !factorByKey.has(key)), + referencedFactorKeys, + ); for (const entry of payload.moodEntries) { const moodLoggedAt = new Date(entry.loggedAt); @@ -734,14 +759,20 @@ const handler = apiHandler( await tx.moodEntryTagLink.deleteMany({ where: { moodEntryId: restored.id }, }); - if (entry.factors.length > 0) { - await tx.moodEntryTagLink.createMany({ - data: entry.factors.map((factor) => ({ - moodEntryId: restored.id, - moodTagId: factorByKey.get(factor.key)!, - rating: factor.rating, - })), - }); + const factorLinks = entry.factors.flatMap((factor) => { + const moodTagId = factorByKey.get(factor.key); + return moodTagId + ? [ + { + moodEntryId: restored.id, + moodTagId, + rating: factor.rating, + }, + ] + : []; + }); + if (factorLinks.length > 0) { + await tx.moodEntryTagLink.createMany({ data: factorLinks }); } } } @@ -749,7 +780,12 @@ const handler = apiHandler( // v1.15.0 — cycle tables (profile + observed spans + day-logs + // symptom links). Delete-then-recreate, mirroring the contract // above. `notesEncrypted` is restored as ciphertext verbatim. - const cycleCleared = await restoreCycleData(tx, ownerId, payload); + const cycleCleared = await restoreCycleData( + tx, + ownerId, + payload, + skips, + ); // Durable self-context + user-defined metrics. Both ends of this pair // live in `src/lib/export/profile-backup.ts` beside its builder — a @@ -905,15 +941,13 @@ const handler = apiHandler( } } - const symptomKeys = [ - ...new Set( - payload.illnessEpisodes.flatMap((episode) => - episode.dayLogs.flatMap((dayLog) => - dayLog.symptoms.map((symptom) => symptom.key), - ), + const referencedSymptomKeys = payload.illnessEpisodes.flatMap( + (episode) => + episode.dayLogs.flatMap((dayLog) => + dayLog.symptoms.map((symptom) => symptom.key), ), - ), - ]; + ); + const symptomKeys = [...new Set(referencedSymptomKeys)]; const symptomRows = symptomKeys.length === 0 ? [] @@ -924,14 +958,25 @@ const handler = apiHandler( const symptomByKey = new Map( symptomRows.map((symptom) => [symptom.key, symptom.id]), ); - if (symptomByKey.size !== symptomKeys.length) { - const missing = symptomKeys.filter((key) => !symptomByKey.has(key)); - throw new Error( - `Unknown illness symptom keys: ${missing.join(", ")}`, - ); - } + // `IllnessSymptom` has no `userId` column at all — it is a purely + // seeded catalogue, so a key that will not resolve here can ONLY be + // catalogue drift and never a definition the file failed to carry. + // Which makes refusing the file over it the least defensible of the + // three: the file was never able to supply the missing row. + recordUnknownKeys( + skips, + "illnessSymptom", + symptomKeys.filter((key) => !symptomByKey.has(key)), + referencedSymptomKeys, + ); for (const episode of payload.illnessEpisodes) { for (const dayLog of episode.dayLogs) { + const symptomLinks = dayLog.symptoms.flatMap((symptom) => { + const symptomId = symptomByKey.get(symptom.key); + return symptomId + ? [{ symptomId, severity: symptom.severity ?? null }] + : []; + }); await tx.illnessDayLog.create({ data: { ...(dayLog.id ? { id: dayLog.id } : {}), @@ -958,12 +1003,9 @@ const handler = apiHandler( ...(dayLog.updatedAt ? { updatedAt: new Date(dayLog.updatedAt) } : {}), - symptomLinks: { - create: dayLog.symptoms.map((symptom) => ({ - symptomId: symptomByKey.get(symptom.key)!, - severity: symptom.severity ?? null, - })), - }, + ...(symptomLinks.length > 0 + ? { symptomLinks: { create: symptomLinks } } + : {}), }, }); } @@ -1113,7 +1155,7 @@ const handler = apiHandler( }); } - return { + const cleared = { measurements: measurements.count, medications: meds.count, intakeEvents: intake.count, @@ -1140,6 +1182,7 @@ const handler = apiHandler( intradayProfiles: intradayCleared.intradayProfiles, healthScoreRecords: healthScoreCleared.healthScoreRecords, }; + return { cleared, skipped: summarizeRestoreSkips(skips) }; }, { maxWait: 10_000, @@ -1168,6 +1211,19 @@ const handler = apiHandler( return apiError("Restore failed", 500); } + const { cleared, skipped } = outcome; + + // Pinned shape, not free text: a dashboard can alert on + // `restoreSkippedLinks > 0` and the key list says which catalogue drifted. + annotate({ + meta: { + restoreSkippedLinks: skipped.links, + restoreSkippedKeys: skipped.catalogueKeys + .map((entry) => `${entry.catalogue}:${entry.key}`) + .join(","), + }, + }); + // v1.4.39.1 — re-fold the persistent measurement rollup tier from // the just-restored measurements. Pre-fix the restore left the // rollup table empty for the owner, so the dashboard chart's @@ -1247,6 +1303,10 @@ const handler = apiHandler( ownerId, ownerUsername: owner.username, cleared, + // The durable half of the report. The response reaches whoever was + // looking at the screen; the audit row is still here next week when + // someone asks why a day-log lost a symptom. + skipped, restored: { measurements: summary.measurements, medications: summary.medications, @@ -1278,6 +1338,7 @@ const handler = apiHandler( const response: RestoreResponse = { restored: true, summary, + skipped, cleared, }; return apiSuccess(response); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 12b79babc..40fed1154 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -13,6 +13,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + AlertTriangle, BookOpen, Database, Download, @@ -45,6 +46,10 @@ import { ListRow } from "@/components/ui/list-row"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BackupRow, BackupsList } from "@/types/backups"; +import type { + RestoreSkipSummary, + SkippedCatalogue, +} from "@/lib/export/restore-skips"; import { getApiErrorMessage } from "./_shared"; import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; @@ -171,6 +176,27 @@ function formatBackupType( return type; } +/** + * Which catalogue a skipped key belonged to, as a label. + * + * Spelled out with literal `t()` calls rather than an interpolated key, so + * `i18n-call-site-coverage.test.ts` can see each one and prove the bundle has + * it. An interpolated key is invisible to that guard and shows up as raw dot + * notation in production the day someone renames a catalogue. + */ +function catalogueLabel( + catalogue: SkippedCatalogue, + t: ReturnType["t"], +) { + if (catalogue === "cycleSymptom") { + return t("admin.section.backups.restoreSkippedCycleSymptom"); + } + if (catalogue === "illnessSymptom") { + return t("admin.section.backups.restoreSkippedIllnessSymptom"); + } + return t("admin.section.backups.restoreSkippedMoodFactor"); +} + export function BackupsSection() { const { t } = useTranslations(); const fmt = useFormatters(); @@ -301,6 +327,12 @@ export function BackupsSection() { if (file) upload.mutate(file); } + // What the last restore could not put back. Held in state rather than left + // to the toast: a toast for "twelve symptom links are gone" disappears in + // four seconds and the operator has no way to get it back. This panel stays + // until they dismiss it. + const [skipped, setSkipped] = useState(null); + // Restore: typed-confirmation dialog. The mutation is keyed by row id // and used inline by `` below — keeping the // mutation here lets the parent invalidate the list query on success. @@ -310,14 +342,28 @@ export function BackupsSection() { // destructive transaction. Include the row id so two different // backups can both be restored independently in the same minute. const idempotencyKey = `restore-${row.id}-${crypto.randomUUID()}`; - return apiPost<{ restored: true }>( + return apiPost<{ restored: true; skipped?: RestoreSkipSummary }>( `/api/admin/backups/${row.id}/restore`, { confirm: "RESTORE" }, { headers: { "Idempotency-Key": idempotencyKey } }, ); }, - onSuccess: () => { - toast.success(t("admin.section.backups.restoreSuccess")); + onSuccess: (data) => { + // A restore that dropped links is not a plain success and must not read + // as one. The count and the exact keys go on screen; the same report is + // in the audit row for anyone asking later. + const report = data.skipped; + if (report && report.links > 0) { + setSkipped(report); + toast.warning( + t("admin.section.backups.restoreSkippedToast", { + links: String(report.links), + }), + ); + } else { + setSkipped(null); + toast.success(t("admin.section.backups.restoreSuccess")); + } queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); // Restore touches every personal-data table; nuke the broader // cache so dashboards / lists rebuild against the new state. @@ -467,6 +513,62 @@ export function BackupsSection() { + {/* The restore's honesty panel. Rendered only after a restore that + actually dropped something, and it names every key rather than + totalling them away — an operator who cannot see WHICH symptom went + missing cannot decide whether it mattered. */} + {skipped && skipped.links > 0 ? ( +
+
+
+
+ +
+
+ ) : null} + {isLoading ? (
diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index efbfbd895..048e52f8c 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -26,6 +26,10 @@ import type { SecondarySymptom, } from "@/generated/prisma/client"; import type { BackupPayload } from "@/lib/validations/backup"; +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; /** The cycle slice of a backup payload. */ export interface CycleBackupOptions { @@ -375,6 +379,11 @@ function enumOrNull( * file recorded for it, or none if the file recorded none. `notesEncrypted` is * written back as the ciphertext envelope verbatim — never re-encrypted. * + * A key that resolves against nothing is dropped from the day's links and + * recorded on `skips`. It is a required argument rather than an optional one: + * a caller that forgets to pass it would restore exactly as before and report + * nothing, which is the failure this reporting exists to end. + * * Returns the wiped counts for the audit trail. A pre-v1.15 payload (empty * cycle arrays + null profile) wipes nothing and recreates nothing. */ @@ -382,6 +391,7 @@ export async function restoreCycleData( tx: Prisma.TransactionClient, ownerId: string, payload: BackupPayload, + skips: RestoreSkipLog, ): Promise { // Wipe (child links cascade off the day-log delete). const dayLogs = await tx.cycleDayLog.deleteMany({ @@ -470,9 +480,10 @@ export async function restoreCycleData( } // Resolve the symptom catalogue once for the link re-creation. - const allKeys = Array.from( - new Set(payload.cycleDayLogs.flatMap((d) => d.symptomKeys ?? [])), + const referencedKeys = payload.cycleDayLogs.flatMap( + (d) => d.symptomKeys ?? [], ); + const allKeys = Array.from(new Set(referencedKeys)); const symptomIdByKey = new Map(); if (allKeys.length > 0) { const rows = await tx.cycleSymptom.findMany({ @@ -484,19 +495,18 @@ export async function restoreCycleData( }); for (const r of rows) symptomIdByKey.set(r.key, r.id); - // Three lines below, an unknown CYCLE reference throws. An unknown symptom - // used to be filtered out instead: the day-log came back with one of its - // symptoms quietly missing, and the restore reported success. Same - // function, same class of missing reference, two different answers — so - // this one is loud now too. + // A cycle reference below still throws, and that difference is the point. + // A `cycleId` names a row THIS FILE was supposed to carry, so a dangling + // one means the file contradicts itself and nothing good comes of writing + // it. A symptom key names a row the INSTANCE owns: the seeded catalogue is + // reference data that drifts across releases, so a key the file wrote a + // year ago and this instance no longer seeds is an ordinary, expected + // mismatch. Refusing the file over it threw away every measurement, every + // dose, and every note to protect a symptom chip. The link is dropped and + // named instead — see `restore-skips.ts` for why neither throwing nor + // filtering in silence was an acceptable answer. const unresolved = allKeys.filter((k) => !symptomIdByKey.has(k)); - if (unresolved.length > 0) { - throw new Error( - `Unknown cycle symptom keys: ${unresolved.join(", ")}. ` + - "The backup references symptoms that are neither in this instance's " + - "seeded catalogue nor carried in the file.", - ); - } + recordUnknownKeys(skips, "cycleSymptom", unresolved, referencedKeys); } // Recreate day-logs (with symptom links). The owning cycle is the latest @@ -524,13 +534,15 @@ export async function restoreCycleData( if (severity !== null) severityByKey.set(s.key, severity); } - // Every key was proven resolvable above, so this maps rather than filters: - // a `.filter(Boolean)` here would silently re-open the hole that check - // just closed. - const symptomLinks = (d.symptomKeys ?? []).map((k) => { + // Every unresolvable key was counted into `skips` above, so dropping it + // here is a reported drop rather than a silent one. The rest of the day — + // flow, temperature, the encrypted note, every observation the person + // actually wrote — is unaffected by a symptom chip that will not resolve. + const symptomLinks = (d.symptomKeys ?? []).flatMap((k) => { const id = symptomIdByKey.get(k); - if (!id) throw new Error(`Unresolved cycle symptom key: ${k}`); - return { symptomId: id, severity: severityByKey.get(k) ?? null }; + return id + ? [{ symptomId: id, severity: severityByKey.get(k) ?? null }] + : []; }); const cycleId = d.cycleId !== undefined diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts new file mode 100644 index 000000000..e57756edd --- /dev/null +++ b/src/lib/export/restore-skips.ts @@ -0,0 +1,89 @@ +/** + * 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 + * 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 + * releases newer. That is the ordinary case for a backup, not a corrupt file. + * + * Each of those lookups used to throw, which rolled the whole transaction back + * and answered 500 — one renamed symptom and the operator got none of the + * account back. Before that they filtered the unresolvable key away and + * reported success, which lost the same link with no trace at all. Neither is + * honest: the first destroys a recoverable restore, the second hides a real + * loss. + * + * So the unresolvable link is dropped and NAMED. This module is the naming. + * Every drop lands here, and the accumulated report rides the restore response, + * the audit row, and the wide event, so the count and the exact keys reach the + * person who ran the restore instead of a log line nobody reads. + * + * What is dropped is deliberately narrow: one (row, symptom) association. The + * day-log, the mood entry, the note, the flow, the temperature — everything the + * row itself holds — comes back. A link table is the only place this applies, + * because it is the only place where a value that will not resolve costs an + * edge rather than a record. + */ + +/** Which seeded catalogue a key failed to resolve against. */ +export type SkippedCatalogue = "cycleSymptom" | "illnessSymptom" | "moodFactor"; + +/** One key this instance does not know, and the links it cost. */ +export interface SkippedCatalogueKey { + catalogue: SkippedCatalogue; + /** The key exactly as the file wrote it. Reported verbatim so an operator + * can grep the file for it and see which days it was on. */ + key: string; + /** How many links referenced it — twelve day-logs is twelve, not one. */ + links: number; +} + +/** Mutable accumulator threaded through one restore transaction. */ +export type RestoreSkipLog = SkippedCatalogueKey[]; + +/** The report shape carried by the response, the audit row, and the UI. */ +export interface RestoreSkipSummary { + /** Distinct unknown keys, ordered by catalogue then key. */ + catalogueKeys: SkippedCatalogueKey[]; + /** Total links dropped across every catalogue. Zero means nothing was lost. */ + links: number; +} + +/** + * Record the keys a catalogue lookup could not resolve. + * + * `referenced` is the FLAT list of keys the file used, one entry per link, not + * the deduplicated set the lookup queried with. A key that appears on twelve + * day-logs cost twelve links, and reporting it as one would understate the loss + * by a factor of twelve — which is the quiet-drop failure again, wearing a + * number. + */ +export function recordUnknownKeys( + log: RestoreSkipLog, + catalogue: SkippedCatalogue, + unresolved: readonly string[], + referenced: readonly string[], +): void { + for (const key of unresolved) { + log.push({ + catalogue, + key, + links: referenced.filter((candidate) => candidate === key).length, + }); + } +} + +/** Fold the accumulator into the report the callers surface. */ +export function summarizeRestoreSkips(log: RestoreSkipLog): RestoreSkipSummary { + const catalogueKeys = [...log].sort((a, b) => + a.catalogue === b.catalogue + ? a.key.localeCompare(b.key) + : a.catalogue.localeCompare(b.catalogue), + ); + return { + catalogueKeys, + links: catalogueKeys.reduce((total, entry) => total + entry.links, 0), + }; +} diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 56d6f9687..50c4509c2 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -1074,6 +1074,20 @@ describe("canonical disaster-recovery backup round-trip", () => { expect(invalidateUserData).not.toHaveBeenCalled(); }); + /** + * The contract here is the rollback, not the trigger. The trigger used to be + * a mood factor key the instance did not know, and that is no longer a + * failure at all: a seeded catalogue legitimately drifts between the day a + * backup is written and the day it is read, so an unresolvable catalogue key + * now drops one link and names it in the response rather than costing the + * whole file. + * + * An intake event naming a medication the file does not carry is a different + * animal and still throws. That reference points at a row THIS FILE was + * supposed to contain, so a dangling one means the file contradicts itself, + * and writing a dose history against a medication that does not exist is not + * a recoverable restore. It is the honest trigger for the rollback contract. + */ it("rolls back a failed full restore before invalidating caches", async () => { const prisma = getPrismaClient(); const admin = await seedAdminSession(); @@ -1092,6 +1106,15 @@ describe("canonical disaster-recovery backup round-trip", () => { exportedAt: "2026-07-04T08:00:00.000Z", userId: admin.id, measurements: [], + medications: [], + intakeEvents: [ + { + id: "failed-restore-intake", + medication: "a medication this file never carried", + scheduledFor: "2026-07-04T08:00:00.000Z", + takenAt: "2026-07-04T08:05:00.000Z", + }, + ], moodEntries: [ { id: "failed-restore-mood", @@ -1100,7 +1123,7 @@ describe("canonical disaster-recovery backup round-trip", () => { score: 3, tags: null, loggedAt: "2026-07-04T08:00:00.000Z", - factors: [{ key: "missing-backup-factor", rating: 4 }], + factors: [], }, ], }); diff --git a/tests/integration/backup-unknown-catalogue-key-restore.test.ts b/tests/integration/backup-unknown-catalogue-key-restore.test.ts new file mode 100644 index 000000000..1a1082a28 --- /dev/null +++ b/tests/integration/backup-unknown-catalogue-key-restore.test.ts @@ -0,0 +1,419 @@ +/** + * A catalogue key the instance no longer knows must not cost the file. + * + * Three of the restore's lookups resolve a key against a seeded catalogue — + * cycle symptoms, illness symptoms, mood factors. Each of them used to throw on + * a key it could not resolve, and the throw was inside the one transaction that + * wipes and rebuilds the account. So one retired symptom rolled everything back: + * no measurements, no doses, no notes, no cycle history, HTTP 500. A backup a + * year old, restored onto an instance three releases newer, is the ordinary + * case for a backup, not a corrupt file. + * + * The link is dropped and reported now. This test proves both halves against + * the REAL admin restore route and against ROWS, never against a helper's + * return value: + * + * - everything else in the file lands (the measurement is the load-bearing + * assertion — it is the data the old behaviour destroyed to protect a + * symptom chip), + * - the surviving symptom keeps its link, + * - the retired key's link is gone AND named in the response, with the count, + * - the retired key is not resurrected in the catalogue: a restore does not + * get to invent reference data on the instance's behalf. + * + * Every owner row is deleted before the restore runs. That is the vacuousness + * guard, not tidiness: leave the fixture in place and a restore that did + * nothing at all would still find the rows sitting there and pass. + * + * Mutation check: put the `throw new Error("Unknown cycle symptom keys: …")` + * back in `restoreCycleData` and this test fails on `expected 500 to be 200`. + * Excise the whole `prisma.$transaction` body and it fails on the measurement + * that never came back. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +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 { invalidateUserData } from "@/lib/cache/invalidate"; + +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(), +})); + +/** + * Catalogue rows the fixture owns, so the test never mutates the migration + * seed other tests in the same container depend on. `userId: null` on the two + * cycle rows is what makes them CATALOGUE rows: the payload builder carries + * only `where: { userId }` symptoms in `customSymptoms`, so a NULL-owner key + * the instance drops afterwards has nothing in the file that could put it back. + * That is exactly the drift being modelled. + */ +const KEPT_CYCLE_KEY = "test:cycle-symptom-that-stays"; +const RETIRED_CYCLE_KEY = "test:cycle-symptom-retired-later"; +const KEPT_ILLNESS_KEY = "test:illness-symptom-that-stays"; +const RETIRED_ILLNESS_KEY = "test:illness-symptom-retired-later"; +const KEPT_MOOD_KEY = "test:mood-factor-that-stays"; +const RETIRED_MOOD_KEY = "test:mood-factor-retired-later"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +describe("a backup naming a catalogue key this instance retired", () => { + it("restores the file, drops only the unresolvable links, and names them", async () => { + const prisma = getPrismaClient(); + + // Owner and operator are the same account so the fixture stays about the + // catalogue rather than about the ownership guard. + const owner = await prisma.user.create({ + data: { + username: "catalogue-drift-owner", + email: "catalogue-drift-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + /* ── the catalogues, before the drift ──────────────────────────────── */ + + const cycleCategory = await prisma.cycleSymptomCategory.findFirstOrThrow(); + const keptCycleSymptom = await prisma.cycleSymptom.create({ + data: { + categoryId: cycleCategory.id, + key: KEPT_CYCLE_KEY, + labelKey: "cycle.symptom.kept", + sortOrder: 1, + }, + }); + const retiredCycleSymptom = await prisma.cycleSymptom.create({ + data: { + categoryId: cycleCategory.id, + key: RETIRED_CYCLE_KEY, + labelKey: "cycle.symptom.retired", + sortOrder: 2, + }, + }); + + const keptIllnessSymptom = await prisma.illnessSymptom.create({ + data: { key: KEPT_ILLNESS_KEY, labelKey: "illness.symptom.kept" }, + }); + const retiredIllnessSymptom = await prisma.illnessSymptom.create({ + data: { key: RETIRED_ILLNESS_KEY, labelKey: "illness.symptom.retired" }, + }); + + const moodCategory = await prisma.moodTagCategory.findFirstOrThrow({ + where: { userId: null }, + }); + const keptMoodTag = await prisma.moodTag.create({ + data: { + categoryId: moodCategory.id, + key: KEPT_MOOD_KEY, + labelKey: "mood.factor.kept", + kind: "RATED", + scaleMin: 1, + scaleMax: 5, + }, + }); + const retiredMoodTag = await prisma.moodTag.create({ + data: { + categoryId: moodCategory.id, + key: RETIRED_MOOD_KEY, + labelKey: "mood.factor.retired", + kind: "RATED", + scaleMin: 1, + scaleMax: 5, + }, + }); + + /* ── the account's data ────────────────────────────────────────────── */ + + // The measurement carries no catalogue reference at all, which is the + // point: it is what the whole-file refusal was destroying. + await prisma.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 81.4, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-03T06:30:00.000Z"), + }, + }); + + const cycle = await prisma.menstrualCycle.create({ + data: { + userId: owner.id, + startDate: "2026-07-01", + periodEndDate: "2026-07-05", + tz: "Europe/Berlin", + }, + }); + await prisma.cycleDayLog.create({ + data: { + userId: owner.id, + cycleId: cycle.id, + date: "2026-07-03", + flow: "MEDIUM", + source: "MANUAL", + tz: "Europe/Berlin", + symptomLinks: { + create: [ + { symptomId: keptCycleSymptom.id, severity: 2 }, + { symptomId: retiredCycleSymptom.id, severity: 4 }, + ], + }, + }, + }); + + // A SECOND day carrying the retired symptom. The report has to count links, + // not distinct keys: one key across two days is two lost links, and + // reporting it as one understates the loss by half. + await prisma.cycleDayLog.create({ + data: { + userId: owner.id, + cycleId: cycle.id, + date: "2026-07-04", + flow: "LIGHT", + source: "MANUAL", + tz: "Europe/Berlin", + symptomLinks: { create: [{ symptomId: retiredCycleSymptom.id }] }, + }, + }); + + const episode = await prisma.illnessEpisode.create({ + data: { + userId: owner.id, + label: "Summer cold", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: new Date("2026-07-02T00:00:00.000Z"), + dayLogs: { + create: { + userId: owner.id, + date: "2026-07-03", + functionalImpact: 2, + symptomLinks: { + create: [ + { symptomId: keptIllnessSymptom.id, severity: 1 }, + { symptomId: retiredIllnessSymptom.id, severity: 3 }, + ], + }, + }, + }, + }, + }); + + const moodEntry = await prisma.moodEntry.create({ + data: { + userId: owner.id, + date: "2026-07-03", + mood: "GOOD", + score: 4, + source: "MOODLOG", + moodLoggedAt: new Date("2026-07-03T19:00:00.000Z"), + tagLinks: { + create: [ + { moodTagId: keptMoodTag.id, rating: 3 }, + { moodTagId: retiredMoodTag.id, rating: 5 }, + ], + }, + }, + }); + + /* ── the real writer, the real wire format, the real reader ────────── */ + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await prisma.dataBackup.create({ + data: { + userId: owner.id, + type: "CATALOGUE_DRIFT_ROUND_TRIP", + data: encrypt(JSON.stringify(parsed)), + }, + }); + + /* ── the drift: three keys the instance stops carrying ─────────────── */ + + await prisma.cycleSymptom.delete({ where: { id: retiredCycleSymptom.id } }); + await prisma.illnessSymptom.delete({ + where: { id: retiredIllnessSymptom.id }, + }); + await prisma.moodTag.delete({ where: { id: retiredMoodTag.id } }); + + // Every owner row goes before the restore runs, so nothing below can pass + // on a row the fixture left behind. A restore that did nothing at all has + // to fail this test. + await prisma.measurement.deleteMany({ where: { userId: owner.id } }); + await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } }); + await prisma.menstrualCycle.deleteMany({ where: { userId: owner.id } }); + await prisma.illnessEpisode.deleteMany({ where: { userId: owner.id } }); + await prisma.moodEntry.deleteMany({ where: { userId: owner.id } }); + expect( + await prisma.measurement.count({ where: { userId: owner.id } }), + ).toBe(0); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + // The whole file came back. Pre-fix this was 500 and the account was empty. + expect(response.status).toBe(200); + + /* ── what actually landed in the database ──────────────────────────── */ + + // The data the old behaviour threw away to protect a symptom chip. + const measurements = await prisma.measurement.findMany({ + where: { userId: owner.id }, + select: { type: true, value: true, unit: true }, + }); + expect(measurements).toEqual([{ type: "WEIGHT", value: 81.4, unit: "kg" }]); + + const restoredDayLogs = await prisma.cycleDayLog.findMany({ + where: { userId: owner.id }, + orderBy: { date: "asc" }, + include: { symptomLinks: { include: { symptom: true } } }, + }); + // Both days come back, including the one whose ONLY symptom was retired. + // A day-log stripped of its last link is still the day the person logged: + // the flow, the timezone, the note are all still theirs. + expect(restoredDayLogs.map((d) => d.flow)).toEqual(["MEDIUM", "LIGHT"]); + // The surviving symptom keeps its link AND its intensity; the retired one + // is simply not there. + expect( + restoredDayLogs[0].symptomLinks.map((link) => ({ + key: link.symptom.key, + severity: link.severity, + })), + ).toEqual([{ key: KEPT_CYCLE_KEY, severity: 2 }]); + expect(restoredDayLogs[1].symptomLinks).toEqual([]); + + const restoredIllnessDayLog = await prisma.illnessDayLog.findFirstOrThrow({ + where: { userId: owner.id }, + include: { symptomLinks: { include: { symptom: true } } }, + }); + expect(restoredIllnessDayLog.episodeId).toBe(episode.id); + expect(restoredIllnessDayLog.functionalImpact).toBe(2); + expect( + restoredIllnessDayLog.symptomLinks.map((link) => ({ + key: link.symptom.key, + severity: link.severity, + })), + ).toEqual([{ key: KEPT_ILLNESS_KEY, severity: 1 }]); + + const restoredMood = await prisma.moodEntry.findFirstOrThrow({ + where: { userId: owner.id }, + include: { tagLinks: { include: { moodTag: true } } }, + }); + expect(restoredMood.id).toBe(moodEntry.id); + expect(restoredMood.score).toBe(4); + expect( + restoredMood.tagLinks.map((link) => ({ + key: link.moodTag.key, + rating: link.rating, + })), + ).toEqual([{ key: KEPT_MOOD_KEY, rating: 3 }]); + + /* ── what the operator is told ─────────────────────────────────────── */ + + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + // Four links across three keys — the cycle symptom was on two days. + expect(body.data.skipped.links).toBe(4); + expect(body.data.skipped.catalogueKeys).toEqual([ + { catalogue: "cycleSymptom", key: RETIRED_CYCLE_KEY, links: 2 }, + { catalogue: "illnessSymptom", key: RETIRED_ILLNESS_KEY, links: 1 }, + { catalogue: "moodFactor", key: RETIRED_MOOD_KEY, links: 1 }, + ]); + + // The durable half. A screen gets closed; the audit row is still here. + const auditRow = await prisma.auditLog.findFirstOrThrow({ + where: { action: "admin.backups.restore" }, + orderBy: { createdAt: "desc" }, + }); + // `AuditLog.details` is a JSON string column, not a Json column. + const auditDetails = JSON.parse(auditRow.details ?? "{}") as { + skipped?: { + links?: number; + catalogueKeys?: Array<{ key: string }>; + }; + }; + expect(auditDetails.skipped?.links).toBe(4); + expect(auditDetails.skipped?.catalogueKeys?.map((e) => e.key)).toEqual([ + RETIRED_CYCLE_KEY, + RETIRED_ILLNESS_KEY, + RETIRED_MOOD_KEY, + ]); + + // A restore reads reference data; it does not get to write it. Minting the + // retired keys back into the catalogue would preserve the links at the cost + // of a row that collides with whatever the next migration seeds under that + // key, and of a label nothing in the app can translate. + expect( + await prisma.cycleSymptom.count({ where: { key: RETIRED_CYCLE_KEY } }), + ).toBe(0); + expect( + await prisma.illnessSymptom.count({ + where: { key: RETIRED_ILLNESS_KEY }, + }), + ).toBe(0); + expect( + await prisma.moodTag.count({ where: { key: RETIRED_MOOD_KEY } }), + ).toBe(0); + }); +}); diff --git a/tests/integration/cycle-import-export.test.ts b/tests/integration/cycle-import-export.test.ts index b0060a518..73eed32e2 100644 --- a/tests/integration/cycle-import-export.test.ts +++ b/tests/integration/cycle-import-export.test.ts @@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export"; import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup"; +import type { RestoreSkipLog } from "@/lib/export/restore-skips"; import { parseBackupPayload } from "@/lib/validations/backup"; process.env.ENCRYPTION_KEY ??= @@ -219,9 +220,14 @@ describe("cycle backup — round-trips through build + restore", () => { }); // Restore into a clean transaction (wipes then recreates). + const skips: RestoreSkipLog = []; await prisma.$transaction(async (tx) => { - await restoreCycleData(tx, USER_ID, payload); + await restoreCycleData(tx, USER_ID, payload, skips); }); + // A file whose every symptom resolves reports nothing skipped. Absence + // reading as absence cuts both ways: the report has to stay empty when + // nothing was lost, or an operator learns to ignore it. + expect(skips).toEqual([]); const restored = await prisma.cycleDayLog.findMany({ where: { userId: USER_ID }, From 63075c47d47c0129b772cfe1b33db138135fd5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 09:10:58 +0200 Subject: [PATCH 2/2] test(backup): prove the skip report reaches the screen, not just the wire The server names every unresolved catalogue key in its response and in the audit row, and the integration suite holds that end. This is the other one: a report nobody sees is the same silence the naming replaced. The panel moves into its own exported component so it can be rendered against a report directly. The SSR smoke tests in this directory cannot drive a state transition, and a render proven only by reading it is not proven. Pinned: every key is printed rather than summed away, each key carries its own count and catalogue, an empty report renders nothing at all, and the copy resolves in a second locale. --- .../backups-restore-skip-report.test.tsx | 81 +++++++++++ src/components/admin/backups-section.tsx | 130 ++++++++++-------- 2 files changed, 157 insertions(+), 54 deletions(-) create mode 100644 src/components/admin/__tests__/backups-restore-skip-report.test.tsx diff --git a/src/components/admin/__tests__/backups-restore-skip-report.test.tsx b/src/components/admin/__tests__/backups-restore-skip-report.test.tsx new file mode 100644 index 000000000..5a23df5af --- /dev/null +++ b/src/components/admin/__tests__/backups-restore-skip-report.test.tsx @@ -0,0 +1,81 @@ +/** + * The restore's skip report has to reach a human. + * + * The server names every catalogue key it could not resolve, in the response + * and in the audit row, and the integration suite proves that end. This is the + * other end: a report that a person never sees is the same silence the naming + * exists to end, so the panel gets its own coverage rather than riding on the + * fact that a state setter is wired up somewhere. + * + * Pinned here: + * 1. every skipped key is printed, not just a total, + * 2. each key carries its own link count and its catalogue, + * 3. an empty report renders nothing at all — a panel that appears after a + * clean restore trains the operator to ignore it, + * 4. the copy resolves in more than one locale, so a self-hoster running the + * German UI is told the same thing. + * + * Mutation check: total the links instead of listing them (drop the `
    `) and + * cases 1 and 2 go red; return the panel unconditionally and case 3 goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { RestoreSkipReport } from "@/components/admin/backups-section"; +import type { RestoreSkipSummary } from "@/lib/export/restore-skips"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/backups", +})); + +const REPORT: RestoreSkipSummary = { + links: 4, + catalogueKeys: [ + { catalogue: "cycleSymptom", key: "cs.retired_symptom", links: 2 }, + { catalogue: "illnessSymptom", key: "ill.retired_symptom", links: 1 }, + { catalogue: "moodFactor", key: "mood.retired_factor", links: 1 }, + ], +}; + +function render(report: RestoreSkipSummary, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +describe("RestoreSkipReport", () => { + it("names every skipped key rather than only the total", () => { + const html = render(REPORT); + expect(html).toContain('data-slot="restore-skip-report"'); + for (const entry of REPORT.catalogueKeys) { + expect(html).toContain(entry.key); + } + }); + + it("gives each key its own link count and catalogue", () => { + const html = render(REPORT); + // The cycle key cost two links and the other two cost one each. A panel + // that printed the grand total against every row would read as six. + expect(html).toContain("2 links"); + expect(html).toContain("1 links"); + expect(html).toContain("Cycle symptom"); + expect(html).toContain("Illness symptom"); + expect(html).toContain("Mood factor"); + expect(html).toContain("4 links were not restored"); + }); + + it("renders nothing when the restore dropped nothing", () => { + expect(render({ links: 0, catalogueKeys: [] })).toBe(""); + }); + + it("speaks the operator's language", () => { + const german = render(REPORT, "de"); + expect(german).toContain("4 Verknüpfungen wurden nicht wiederhergestellt"); + expect(german).toContain("Zyklussymptom"); + expect(german).toContain("cs.retired_symptom"); + }); +}); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 40fed1154..36fa27874 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -197,6 +197,77 @@ function catalogueLabel( return t("admin.section.backups.restoreSkippedMoodFactor"); } +/** + * What the last restore could not put back. + * + * Its own component, and exported, so it can be rendered against a report in a + * test instead of only through a state transition the SSR smoke tests here + * cannot drive. A report that reaches the response and never reaches the screen + * is the same silence the reporting exists to end, so the render is worth + * proving on its own. + * + * It names every key rather than totalling them away: an operator who cannot + * see WHICH symptom went missing cannot decide whether it mattered. + */ +export function RestoreSkipReport({ + report, + onDismiss, +}: { + report: RestoreSkipSummary; + onDismiss: () => void; +}) { + const { t } = useTranslations(); + if (report.links === 0) return null; + return ( +
    +
    +
    +
    + +
    +
    + ); +} + export function BackupsSection() { const { t } = useTranslations(); const fmt = useFormatters(); @@ -513,60 +584,11 @@ export function BackupsSection() {
- {/* The restore's honesty panel. Rendered only after a restore that - actually dropped something, and it names every key rather than - totalling them away — an operator who cannot see WHICH symptom went - missing cannot decide whether it mattered. */} - {skipped && skipped.links > 0 ? ( -
-
-
-
- -
-
+ {skipped ? ( + setSkipped(null)} + /> ) : null} {isLoading ? (