From 4e95bd28f80086e02f183f5541fcac5f04af5d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 21:45:10 +0200 Subject: [PATCH] fix(import): stop the Apple Health card ticking an archive that landed nothing Same shape as the CSV and JSON cards: reaching the done state rendered the success tick whether or not a single row was upserted. The archive's count picks the tone now, and an archive that wrote nothing says so. The clinical-record count stays out of that decision. Skipping electrocardiograms and lab documents is a documented, deliberate exclusion, not a refused row, so it must not read as a warning. Refs #640 --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../api/import/csv/__tests__/route.test.ts | 84 +++++++ .../__tests__/import-result-guard.test.ts | 75 ++++++ .../__tests__/import-result-state.test.ts | 94 ++++++++ .../__tests__/import-result-view.test.tsx | 180 +++++++++++++++ .../import-panel/apple-health-import-card.tsx | 42 ++-- .../import/__tests__/csv-measurements.test.ts | 45 +++- .../csv-import-contextless-glucose.test.ts | 215 ++++++++++++++++++ 13 files changed, 719 insertions(+), 22 deletions(-) create mode 100644 src/components/settings/import-panel/__tests__/import-result-guard.test.ts create mode 100644 src/components/settings/import-panel/__tests__/import-result-state.test.ts create mode 100644 src/components/settings/import-panel/__tests__/import-result-view.test.tsx create mode 100644 tests/integration/csv-import-contextless-glucose.test.ts diff --git a/messages/de.json b/messages/de.json index 05226a816..0457825b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4472,6 +4472,7 @@ "failed": "Der Import ist fehlgeschlagen.", "rowsImported": "Bisher {count} Einträge importiert", "doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.", + "doneNothing": "Aus diesem Archiv wurde nichts importiert: 0 von {read} Datensätzen sind angekommen.", "estimateWarning": "Kumulative Summen für {count} Tag(e) wurden anhand der größten Exportquelle geschätzt. Native Apple-Health-Statistiken bleiben maßgeblich.", "phase": { "queued": "In Warteschlange…", diff --git a/messages/en.json b/messages/en.json index da58b3e62..37e2e2186 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4462,6 +4462,7 @@ "failed": "The import failed.", "rowsImported": "{count} rows imported so far", "doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.", + "doneNothing": "Nothing was imported from that archive: 0 of {read} records landed.", "estimateWarning": "Cumulative totals for {count} day(s) were estimated from the largest export source. Native Apple Health statistics remain authoritative.", "phase": { "queued": "Queued…", diff --git a/messages/es.json b/messages/es.json index 0e94c76ed..5430a002a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4474,6 +4474,7 @@ "failed": "La importación falló.", "rowsImported": "{count} filas importadas hasta ahora", "doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.", + "doneNothing": "No se importó nada de ese archivo: llegaron 0 de {read} registros.", "estimateWarning": "Los totales acumulados de {count} día(s) se estimaron a partir de la mayor fuente de la exportación. Las estadísticas nativas de Apple Health siguen siendo la referencia.", "phase": { "queued": "En cola…", diff --git a/messages/fr.json b/messages/fr.json index c9809da25..0bc0a7a47 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4476,6 +4476,7 @@ "failed": "L'import a échoué.", "rowsImported": "{count} lignes importées jusqu'ici", "doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.", + "doneNothing": "Rien n'a été importé de cette archive : 0 enregistrement sur {read} est arrivé.", "estimateWarning": "Les totaux cumulés de {count} jour(s) ont été estimés à partir de la source d’export la plus élevée. Les statistiques Apple Santé natives restent la référence.", "phase": { "queued": "En file d'attente…", diff --git a/messages/it.json b/messages/it.json index c5af13184..8064f1679 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4466,6 +4466,7 @@ "failed": "L'importazione non è riuscita.", "rowsImported": "{count} righe importate finora", "doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.", + "doneNothing": "Da questo archivio non è stato importato nulla: sono arrivati 0 record su {read}.", "estimateWarning": "I totali cumulativi di {count} giorno/i sono stati stimati dalla fonte di esportazione più alta. Le statistiche native di Apple Salute restano autorevoli.", "phase": { "queued": "In coda…", diff --git a/messages/pl.json b/messages/pl.json index 4d25b9249..9d18cea87 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4460,6 +4460,7 @@ "failed": "Import nie powiódł się.", "rowsImported": "Zaimportowano dotąd {count} wierszy", "doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.", + "doneNothing": "Z tego archiwum nic nie zaimportowano: dotarło 0 z {read} rekordów.", "estimateWarning": "Łączne wartości dla {count} dni oszacowano na podstawie największego źródła w eksporcie. Natywne statystyki Apple Health pozostają nadrzędne.", "phase": { "queued": "W kolejce…", diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index 5fae65083..70e47ed3e 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -339,3 +339,87 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => { ); }); }); + +describe("POST /api/import/csv — contextless blood glucose", () => { + // #640 — the reported file. A sensor export leaves `glucoseContext` blank + // on every row; the route must build a row with the column NULL rather + // than refusing the reading. + it("writes a contextless sensor reading with the canonical shape", async () => { + const res = await POST( + csvRequest( + [ + HEADER, + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ].join("\n"), + ), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.skipped).toBe(0); + expect(body.data?.rows).toEqual([{ line: 2, status: "inserted" }]); + + const row = mMeasurement().createManyAndReturn.mock.calls[0][0].data[0]; + expect(row).toMatchObject({ + userId: "u-1", + type: "BLOOD_GLUCOSE", + unit: "mg/dL", + source: "IMPORT", + externalId: "sensor-1", + glucoseContext: null, + }); + expect(row.value).toBeCloseTo(95.4848, 3); + expect((row.measuredAt as Date).toISOString()).toBe( + "2024-04-03T02:15:00.000Z", + ); + }); + + it("keeps a re-upload of the same external id idempotent", async () => { + // The key already exists under (userId, type, source=IMPORT, externalId). + mMeasurement().findMany.mockResolvedValue([ + { type: "BLOOD_GLUCOSE", externalId: "sensor-1" }, + ]); + + const res = await POST( + csvRequest( + [ + HEADER, + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ].join("\n"), + ), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(0); + expect(body.data?.updated).toBe(1); + expect(body.data?.rows).toEqual([{ line: 2, status: "updated" }]); + // No second row minted — the existing one is updated in place. + expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled(); + const update = mMeasurement().updateMany.mock.calls[0][0]; + expect(update.where).toMatchObject({ + userId: "u-1", + source: "IMPORT", + type: "BLOOD_GLUCOSE", + externalId: "sensor-1", + }); + expect(update.data).toMatchObject({ glucoseContext: null, unit: "mg/dL" }); + }); + + it("still refuses a context that is not one of the four", async () => { + const res = await POST( + csvRequest( + [HEADER, "BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,LUNCH,,"].join( + "\n", + ), + ), + ); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(0); + expect(body.data?.rows[0]).toMatchObject({ + status: "skipped", + reason: "invalid_glucose_context", + }); + }); +}); diff --git a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts new file mode 100644 index 000000000..ee5f100df --- /dev/null +++ b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts @@ -0,0 +1,75 @@ +/** + * #640 structural guard — "wrote nothing, looked successful" cannot come back. + * + * The classifier property in `import-result-state.test.ts` proves the rule. + * This proves the rule is the only route to the affordance: the success icon + * and the success colour exist in exactly one file in the import panel, on + * exactly one key of the outcome table, and no card reaches for them + * directly. A future card that hand-rolls a green tick fails here rather than + * shipping and lying to a self-hoster about their sensor history. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const PANEL_DIR = join(__dirname, ".."); +const PRESENTATION_FILE = "import-result-view.tsx"; + +/** The success affordance: the tick component and the success colour token. */ +const SUCCESS_MARKERS = ["CheckCircle2", "text-success"] as const; + +function panelSourceFiles(): string[] { + return readdirSync(PANEL_DIR).filter( + (name) => name.endsWith(".ts") || name.endsWith(".tsx"), + ); +} + +/** Code only — a comment naming the marker is prose, not an affordance. */ +function readCode(name: string): string { + return readFileSync(join(PANEL_DIR, name), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); +} + +describe("import panel — success affordance is owned by the outcome table", () => { + it("mentions the success markers in the presentation module only", () => { + const offenders = panelSourceFiles() + .filter((name) => name !== PRESENTATION_FILE) + .filter((name) => { + const source = readCode(name); + return SUCCESS_MARKERS.some((marker) => source.includes(marker)); + }); + expect(offenders).toEqual([]); + }); + + it("binds each success marker to the success outcome inside that module", () => { + const source = readCode(PRESENTATION_FILE); + for (const marker of SUCCESS_MARKERS) { + // Once as the lucide import (the tick only), once on the table row. + const uses = source.split(marker).length - 1; + expect(uses).toBe(marker === "CheckCircle2" ? 2 : 1); + } + // The table row itself: `success:` carries both markers, and no other + // outcome key does. + const successRow = source.match(/success:\s*\{[^}]*\}/)?.[0] ?? ""; + expect(successRow).toContain("CheckCircle2"); + expect(successRow).toContain("text-success"); + for (const other of ["partial", "failed", "empty"]) { + const row = source.match(new RegExp(`${other}:\\s*\\{[^}]*\\}`))?.[0]; + expect(row).toBeTruthy(); + for (const marker of SUCCESS_MARKERS) { + expect(row).not.toContain(marker); + } + } + }); + + it("routes every card's result line through the shared outcome line", () => { + for (const name of [ + "csv-import-card.tsx", + "json-import-card.tsx", + "apple-health-import-card.tsx", + ]) { + expect(readCode(name)).toMatch(/CsvImportResultView|ImportOutcomeLine/); + } + }); +}); diff --git a/src/components/settings/import-panel/__tests__/import-result-state.test.ts b/src/components/settings/import-panel/__tests__/import-result-state.test.ts new file mode 100644 index 000000000..7870b680e --- /dev/null +++ b/src/components/settings/import-panel/__tests__/import-result-state.test.ts @@ -0,0 +1,94 @@ +/** + * #640 — an import that wrote nothing must never read as a success. + * + * The reported failure was not the parser refusing rows; it was the card + * answering that refusal with a green tick, which is what turned a + * recoverable problem into a silent one. These pin the classifier that both + * import cards render from, and the structural guard that keeps the success + * affordance attached to the success outcome alone lives alongside in + * `import-result-guard.test.ts`. + */ +import { describe, it, expect } from "vitest"; + +import { + classifyImportOutcome, + groupSkipReasons, +} from "../import-result-state"; + +describe("classifyImportOutcome", () => { + it("calls the reported production result a failure", () => { + // The audit envelope from the report: 1 597 rows in, nothing written. + expect(classifyImportOutcome({ written: 0, skipped: 1597 })).toBe("failed"); + }); + + it("calls a file with no data rows empty, not a success", () => { + expect(classifyImportOutcome({ written: 0, skipped: 0 })).toBe("empty"); + }); + + it("calls a mixed result partial", () => { + expect(classifyImportOutcome({ written: 3, skipped: 2 })).toBe("partial"); + }); + + it("calls a clean result a success", () => { + expect(classifyImportOutcome({ written: 5, skipped: 0 })).toBe("success"); + }); + + it("never reaches success while nothing was written", () => { + for (let written = 0; written <= 4; written++) { + for (let skipped = 0; skipped <= 4; skipped++) { + const outcome = classifyImportOutcome({ written, skipped }); + if (written === 0) { + expect(outcome).not.toBe("success"); + expect(outcome).not.toBe("partial"); + } + if (outcome === "success") { + expect(written).toBeGreaterThan(0); + expect(skipped).toBe(0); + } + } + } + }); +}); + +describe("groupSkipReasons", () => { + it("collapses a repeated reason to one entry with a count", () => { + const rows = Array.from({ length: 1597 }, (_, i) => ({ + line: i + 2, + status: "skipped", + reason: "invalid_glucose_context", + })); + expect(groupSkipReasons(rows)).toEqual([ + { reason: "invalid_glucose_context", count: 1597 }, + ]); + }); + + it("ignores rows that were written", () => { + expect( + groupSkipReasons([ + { line: 2, status: "inserted" }, + { line: 3, status: "updated" }, + { line: 4, status: "skipped", reason: "unknown_unit" }, + ]), + ).toEqual([{ reason: "unknown_unit", count: 1 }]); + }); + + it("orders by count descending, then by reason", () => { + const rows = [ + { line: 2, status: "skipped", reason: "unknown_unit" }, + { line: 3, status: "skipped", reason: "duplicate" }, + { line: 4, status: "skipped", reason: "duplicate" }, + { line: 5, status: "skipped", reason: "notes_too_long" }, + ]; + expect(groupSkipReasons(rows)).toEqual([ + { reason: "duplicate", count: 2 }, + { reason: "notes_too_long", count: 1 }, + { reason: "unknown_unit", count: 1 }, + ]); + }); + + it("buckets a skipped row that carries no reason", () => { + expect(groupSkipReasons([{ line: 2, status: "skipped" }])).toEqual([ + { reason: "unknown", count: 1 }, + ]); + }); +}); diff --git a/src/components/settings/import-panel/__tests__/import-result-view.test.tsx b/src/components/settings/import-panel/__tests__/import-result-view.test.tsx new file mode 100644 index 000000000..de6506f2f --- /dev/null +++ b/src/components/settings/import-panel/__tests__/import-result-view.test.tsx @@ -0,0 +1,180 @@ +/** + * #640 — the CSV import result renders what actually happened. + * + * Project convention is SSR-only component tests (vitest runs `node`; + * `@testing-library/react` is not installed), so the view takes the result as + * a prop and each state is rendered directly. + * + * The states pinned here are the ones the report separated: a file where + * everything was refused must not carry the success icon or success wording, + * a mixed file reads as a warning, a clean file reads as a success, and a + * file with no data rows gets its own message rather than either. Skip + * reasons render grouped with a count, in plain language, inside the card's + * `aria-live` region so a screen reader hears the outcome too. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/export", +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + CsvImportResultView, + type CsvImportResult, +} from "../import-result-view"; +import { CsvImportCard } from "../csv-import-card"; + +function render(node: React.ReactElement) { + return renderToStaticMarkup( + {node}, + ); +} + +function result(over: Partial = {}): CsvImportResult { + return { + inserted: 0, + updated: 0, + skipped: 0, + total: 0, + dryRun: false, + rows: [], + ...over, + }; +} + +/** The production envelope from the report: nothing written, everything refused. */ +const ALL_SKIPPED = result({ + skipped: 1597, + total: 1597, + rows: Array.from({ length: 1597 }, (_, i) => ({ + line: i + 2, + status: "skipped", + reason: "invalid_glucose_context", + })), +}); + +describe(" — result states", () => { + it("renders an all-skipped import as a failure, not a success", () => { + const html = render(); + expect(html).toContain('data-outcome="failed"'); + expect(html).not.toContain('data-outcome="success"'); + expect(html).not.toContain("text-success"); + expect(html).toContain("Nothing was imported"); + expect(html).toContain("All 1597 rows were skipped"); + }); + + it("renders a mixed import as a warning", () => { + const html = render( + , + ); + expect(html).toContain('data-outcome="partial"'); + expect(html).toContain("text-warning"); + expect(html).not.toContain("text-success"); + }); + + it("renders a clean import as a success", () => { + const html = render( + , + ); + expect(html).toContain('data-outcome="success"'); + expect(html).toContain("text-success"); + expect(html).toContain("Imported 4 new and updated 1."); + expect(html).not.toContain("skipped"); + }); + + it("gives a file with no data rows its own message", () => { + const html = render(); + expect(html).toContain('data-outcome="empty"'); + expect(html).not.toContain("text-success"); + expect(html).toContain("no data rows"); + }); + + it("reports a preview that would import nothing as a failure", () => { + const html = render( + , + ); + expect(html).toContain('data-outcome="failed"'); + expect(html).toContain("nothing would import"); + }); +}); + +describe(" — skip reasons", () => { + it("groups a repeated reason into one counted line, in plain language", () => { + const html = render(); + expect(html).toContain( + "1597 rows skipped: glucose context is not one of the allowed values", + ); + // One grouped line, not 1 597 (and not the old first-fifty list). + const groups = html.split('data-testid="import-csv-skip-groups"')[1] ?? ""; + expect(groups.split("").length - 1).toBeGreaterThan(0); + expect(html.match(/1597 rows skipped/g)).toHaveLength(1); + }); + + it("keeps the per-line detail behind a disclosure", () => { + const html = render( + , + ); + expect(html).toContain(" { + const html = render( + , + ); + expect(html).not.toContain("brand_new_reason"); + expect(html).toContain("1 row skipped: skipped"); + }); +}); + +describe(" — announcement", () => { + it("keeps the result slot inside the polite live region", () => { + const html = render(); + expect(html).toContain('aria-live="polite"'); + // No result yet: the region is present and empty, so the first outcome to + // land is announced rather than being read as initial content. + expect(html).not.toContain('data-testid="import-csv-result"'); + }); +}); diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx index ce22accec..cfeb60c3f 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -2,7 +2,7 @@ import { useCallback, useId, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, CheckCircle2, Loader2, Upload } from "lucide-react"; +import { AlertCircle, Loader2, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; @@ -11,6 +11,8 @@ import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; +import { classifyImportOutcome } from "./import-result-state"; +import { ImportOutcomeLine } from "./import-result-view"; /** Terminal states the status poll stops on. */ const TERMINAL_STATES: readonly string[] = ["done", "failed"]; @@ -237,22 +239,28 @@ export function AppleHealthImportCard() { )} {isDone && ( -
-
+ 0 + ? t("settings.sections.export.import.appleHealth.doneSummary", { + imported: status?.result?.totals?.rowsUpserted ?? 0, + read: status?.result?.totals?.recordsRead ?? 0, + skipped: status?.result?.clinical?.skipped ?? 0, + }) + : t("settings.sections.export.import.appleHealth.doneNothing", { + read: status?.result?.totals?.recordsRead ?? 0, + }) + } + testId="import-apple-health-result" + /> )} {isDone && (status?.result?.cumulativeEstimates?.days ?? 0) > 0 && ( { }); describe("parseCsvMeasurements — glucose context", () => { - it("requires a context for BLOOD_GLUCOSE", () => { + // #640 — a continuous-sensor export carries a value and a timestamp per + // reading and classifies nothing. Refusing those rows rejected the normal + // case; the reading is accepted and the context stays absent. + it("accepts a BLOOD_GLUCOSE row with no context", () => { const out = parse(["BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,,,"]); - expect(out.rows[0]).toMatchObject({ - status: "skipped", - reason: "missing_glucose_context", - }); + expect(out.rows[0]).toMatchObject({ status: "ok" }); + expect(out.rows[0].row).toBeDefined(); + expect(out.rows[0].row).not.toHaveProperty("glucoseContext"); + }); + + it("accepts a contextless mmol/L reading at a +HHMM offset", () => { + const out = parse([ + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-17T08:06:00+1000,,Sensor,sensor-2", + ]); + expect(out.rows.map((r) => r.status)).toEqual(["ok", "ok"]); + for (const result of out.rows) { + expect(result.row).not.toHaveProperty("glucoseContext"); + expect(result.row?.unit).toBe("mg/dL"); + expect(result.row?.value).toBeCloseTo(95.4848, 3); + } + expect(out.rows[0].row?.measuredAt.toISOString()).toBe( + "2024-04-03T02:15:00.000Z", + ); + expect(out.rows[1].row?.measuredAt.toISOString()).toBe( + "2024-04-16T22:06:00.000Z", + ); + }); + + it("still accepts every enum context", () => { + const out = parse( + ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"].map( + (ctx) => `BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,${ctx},,`, + ), + ); + expect(out.rows.map((r) => r.row?.glucoseContext)).toEqual([ + "FASTING", + "POSTPRANDIAL", + "RANDOM", + "BEDTIME", + ]); }); it("rejects a context on a non-glucose row", () => { diff --git a/tests/integration/csv-import-contextless-glucose.test.ts b/tests/integration/csv-import-contextless-glucose.test.ts new file mode 100644 index 000000000..15dde8f74 --- /dev/null +++ b/tests/integration/csv-import-contextless-glucose.test.ts @@ -0,0 +1,215 @@ +/** + * #640 — a contextless blood-glucose reading reaches Postgres. + * + * The reported import wrote nothing. Two layers had to agree that a reading + * without a fasting / post-meal classification is a reading: the CSV parser, + * and the CHECK constraint from migration 0021 that demanded a context on + * every `BLOOD_GLUCOSE` row. Only a real database proves the second half, so + * this runs the route end to end and reads the row back. + * + * Also pinned here: the constraint's surviving half (a context on any other + * type is still refused), the re-upload idempotency the importer promises, + * and the writers that were failing silently against the old constraint + * (the CGM sync shape and the single-value tool shape) now landing. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-csv-glucose"; + +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), +})); + +const HEADER = "type,value,unit,measuredAt,glucoseContext,notes,externalId"; + +async function signIn() { + await getPrismaClient().user.create({ + data: { + id: USER, + username: "csv-glucose", + email: "csv-glucose@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: USER, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function csvRequest(rows: string[]): NextRequest { + return new NextRequest("http://localhost/api/import/csv", { + method: "POST", + headers: { "content-type": "text/csv" }, + body: [HEADER, ...rows].join("\n"), + }); +} + +interface CsvEnvelope { + data: { + inserted: number; + updated: number; + skipped: number; + total: number; + rows: Array<{ line: number; status: string; reason?: string }>; + } | null; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +describe("POST /api/import/csv — contextless glucose (real Postgres)", () => { + it("persists a sensor reading with no context, in the canonical shape", async () => { + await signIn(); + const { POST } = await import("@/app/api/import/csv/route"); + + const res = await POST( + csvRequest([ + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ]), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.skipped).toBe(0); + + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].type).toBe("BLOOD_GLUCOSE"); + expect(rows[0].glucoseContext).toBeNull(); + expect(rows[0].source).toBe("IMPORT"); + expect(rows[0].unit).toBe("mg/dL"); + expect(rows[0].value).toBeCloseTo(95.4848, 3); + expect(rows[0].externalId).toBe("sensor-1"); + expect(rows[0].measuredAt.toISOString()).toBe("2024-04-03T02:15:00.000Z"); + }); + + it("stays idempotent when the same external id is re-uploaded", async () => { + await signIn(); + const { POST } = await import("@/app/api/import/csv/route"); + + const first = await POST( + csvRequest([ + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ]), + ); + expect(((await first.json()) as CsvEnvelope).data?.inserted).toBe(1); + + const second = await POST( + csvRequest([ + "BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ]), + ); + const secondBody = (await second.json()) as CsvEnvelope; + expect(secondBody.data?.inserted).toBe(0); + expect(secondBody.data?.updated).toBe(1); + + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].glucoseContext).toBeNull(); + expect(rows[0].value).toBeCloseTo(106.2944, 3); + }); + + it("keeps a named context, and still refuses one that is not in the enum", async () => { + await signIn(); + const { POST } = await import("@/app/api/import/csv/route"); + + const res = await POST( + csvRequest([ + "BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,FASTING,,meter-1", + "BLOOD_GLUCOSE,95,mg/dL,2026-05-01T09:00:00Z,LUNCH,,meter-2", + ]), + ); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.rows[1]).toMatchObject({ + status: "skipped", + reason: "invalid_glucose_context", + }); + + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].glucoseContext).toBe("FASTING"); + }); +}); + +describe("measurements.glucose_context — surviving CHECK half", () => { + it("still refuses a context on a row that is not blood glucose", async () => { + await signIn(); + await expect( + getPrismaClient().measurement.create({ + data: { + userId: USER, + type: "WEIGHT", + value: 80, + unit: "kg", + source: "IMPORT", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + glucoseContext: "FASTING", + }, + }), + ).rejects.toThrow(); + }); + + it("accepts the contextless shape the device writers build", async () => { + await signIn(); + // The Nightscout SGV upsert and the MCP / Telegram single-value capture + // both build a BLOOD_GLUCOSE row with no context. Under the old + // constraint every one of those inserts was refused by the database. + const sources = ["NIGHTSCOUT", "MCP", "TELEGRAM"] as const; + for (const [index, source] of sources.entries()) { + await getPrismaClient().measurement.create({ + data: { + userId: USER, + type: "BLOOD_GLUCOSE", + value: 95, + unit: "mg/dL", + source, + measuredAt: new Date(`2026-05-01T0${index}:00:00.000Z`), + externalId: `${source}-1`, + }, + }); + } + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER, type: "BLOOD_GLUCOSE" }, + }); + expect(rows).toHaveLength(3); + expect(rows.every((row) => row.glucoseContext === null)).toBe(true); + }); +});