— 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 && (
-
-
-
- {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,
- })}
-
-
+ 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);
+ });
+});