mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
0c9832eb8d
commit
4e95bd28f8
13 files changed
+719
-22
No files matched your search
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user