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
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 21:45:10 +02:00
1 parent 0c9832eb8d
commit 4e95bd28f8
13 files changed
+719 -22

No files matched your search

+1
View File
@@ -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…",
+1
View File
@@ -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…",
+1
View File
@@ -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…",
+1
View File
@@ -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…",
+1
View File
@@ -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…",
+1
View File
@@ -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…",
@@ -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",
});
});
});
@@ -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/);
}
});
});
@@ -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 },
]);
});
});
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
function result(over: Partial<CsvImportResult> = {}): 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("<CsvImportResultView> — result states", () => {
it("renders an all-skipped import as a failure, not a success", () => {
const html = render(<CsvImportResultView result={ALL_SKIPPED} />);
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(
<CsvImportResultView
result={result({
inserted: 3,
skipped: 2,
total: 5,
rows: [
{ line: 2, status: "inserted" },
{ line: 3, status: "inserted" },
{ line: 4, status: "inserted" },
{ line: 5, status: "skipped", reason: "unknown_unit" },
{ line: 6, status: "skipped", reason: "duplicate" },
],
})}
/>,
);
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(
<CsvImportResultView
result={result({
inserted: 4,
updated: 1,
total: 5,
rows: [
{ line: 2, status: "inserted" },
{ line: 3, status: "inserted" },
{ line: 4, status: "inserted" },
{ line: 5, status: "inserted" },
{ line: 6, status: "updated" },
],
})}
/>,
);
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(<CsvImportResultView result={result()} />);
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(
<CsvImportResultView result={{ ...ALL_SKIPPED, dryRun: true }} />,
);
expect(html).toContain('data-outcome="failed"');
expect(html).toContain("nothing would import");
});
});
describe("<CsvImportResultView> — skip reasons", () => {
it("groups a repeated reason into one counted line, in plain language", () => {
const html = render(<CsvImportResultView result={ALL_SKIPPED} />);
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("</li>").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(
<CsvImportResultView
result={result({
skipped: 1,
total: 1,
rows: [{ line: 2, status: "skipped", reason: "unknown_unit" }],
})}
/>,
);
expect(html).toContain("<details");
expect(html).toContain("Show every skipped row");
expect(html).toContain("Line 2: unit not recognised for this type");
});
it("labels a reason it does not know rather than leaking the raw token", () => {
const html = render(
<CsvImportResultView
result={result({
skipped: 1,
total: 1,
rows: [{ line: 2, status: "skipped", reason: "brand_new_reason" }],
})}
/>,
);
expect(html).not.toContain("brand_new_reason");
expect(html).toContain("1 row skipped: skipped");
});
});
describe("<CsvImportCard> — announcement", () => {
it("keeps the result slot inside the polite live region", () => {
const html = render(<CsvImportCard />);
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"');
});
});
@@ -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() {
</div>
)}
{isDone && (
<div
data-testid="import-apple-health-result"
className="text-foreground flex items-start gap-2 text-xs"
>
<CheckCircle2
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{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,
})}
</span>
</div>
<ImportOutcomeLine
outcome={classifyImportOutcome({
written: status?.result?.totals?.rowsUpserted ?? 0,
// The clinical count is a documented, deliberate exclusion
// (electrocardiograms, lab documents), not a refused row — it
// must not tip the archive into a warning. Only "nothing
// landed" changes the tone here.
skipped: 0,
})}
message={
(status?.result?.totals?.rowsUpserted ?? 0) > 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 && (
<AppleHealthEstimateWarning
@@ -157,12 +157,47 @@ describe("parseCsvMeasurements — timezone + entry-instant bound", () => {
});
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", () => {
@@ -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);
});
});