diff --git a/src/components/settings/__tests__/import-panel.test.tsx b/src/components/settings/__tests__/import-panel.test.tsx new file mode 100644 index 000000000..7124cea90 --- /dev/null +++ b/src/components/settings/__tests__/import-panel.test.tsx @@ -0,0 +1,129 @@ +/** + * v1.15.7 — Settings → Export & Import → `` contract suite. + * + * Project convention is SSR-only component tests (vitest runs in the + * `node` environment; `@testing-library/react` is not installed). The + * panel's interactive paths (upload, poll, paste-and-import) are + * exercised end-to-end by the e2e suite; here we pin: + * + * 1. The page-level shape — both import cards render with their stable + * testids, localised copy, and the accessible controls (labelled + * file inputs, keyboard-operable drop area, aria-live status slot). + * 2. The "Download example" payload — the inline example JSON the + * button mints must parse and carry the documented field shape (the + * two arrays, the German-anchored mood enum), so the docs and the + * button never drift from the route. + * 3. The error guard — the JSON import refuses an unparseable paste + * before it ever hits the network (validated through the exported + * helper). + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/export", +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + ImportPanel, + EXAMPLE_IMPORT, + parseImportJson, +} from "../import-panel"; + +function render(node: React.ReactElement, locale: "en" | "de" = "en") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe(" — SSR smoke", () => { + it("renders the import heading and both cards", () => { + const html = render(); + expect(html).toContain('id="settings-section-import-title"'); + expect(html).toContain('data-testid="import-card-apple-health"'); + expect(html).toContain('data-testid="import-card-json"'); + // Raw i18n keys never leak past the provider. + expect(html).not.toContain("settings.sections.export.import."); + }); + + it("exposes the action controls with stable testids", () => { + const html = render(); + for (const id of [ + "import-action-apple-health", + "import-json-textarea", + "import-json-choose-file", + "import-json-download-example", + "import-action-json", + ]) { + expect(html).toContain(`data-testid="${id}"`); + } + }); + + it("gives the Apple Health drop area a keyboard-operable role", () => { + const html = render(); + // A div masquerading as a button must be focusable + role=button so a + // keyboard user can trigger the file picker. + expect(html).toContain('role="button"'); + expect(html).toContain("aria-live"); + }); + + it("labels the hidden file inputs for assistive tech", () => { + const html = render(); + // Both file inputs are visually hidden (sr-only) but must carry an + // accessible name. + expect(html).toContain('type="file"'); + expect(html).toContain("aria-label"); + }); + + it("renders the German copy under the de locale", () => { + const html = render(, "de"); + expect(html).toContain("Import"); + expect(html).not.toContain("settings.sections.export.import."); + }); +}); + +describe("EXAMPLE_IMPORT payload", () => { + it("round-trips through JSON and stays parseable", () => { + const serialised = JSON.stringify(EXAMPLE_IMPORT, null, 2); + const parsed = parseImportJson(serialised); + expect(parsed.ok).toBe(true); + }); + + it("carries both arrays with the documented field shape", () => { + expect(Array.isArray(EXAMPLE_IMPORT.measurements)).toBe(true); + expect(Array.isArray(EXAMPLE_IMPORT.moodEntries)).toBe(true); + const m = EXAMPLE_IMPORT.measurements[0]!; + expect(m).toHaveProperty("type"); + expect(m).toHaveProperty("value"); + expect(m).toHaveProperty("unit"); + expect(m).toHaveProperty("measuredAt"); + const mood = EXAMPLE_IMPORT.moodEntries[0]!; + expect(mood).toHaveProperty("date"); + expect(mood).toHaveProperty("mood"); + expect(mood).toHaveProperty("score"); + // The mood enum values must be the German-anchored server enum. + expect(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]).toContain( + mood.mood, + ); + }); +}); + +describe("parseImportJson guard", () => { + it("rejects an unparseable string before any network call", () => { + const result = parseImportJson("{ not json"); + expect(result.ok).toBe(false); + }); + + it("accepts a minimal valid body", () => { + const result = parseImportJson('{"measurements":[]}'); + expect(result.ok).toBe(true); + }); +}); diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx new file mode 100644 index 000000000..5ee3dfa88 --- /dev/null +++ b/src/components/settings/import-panel.tsx @@ -0,0 +1,622 @@ +"use client"; + +/** + * v1.15.7 — Settings → Export & Import → Import area. + * + * Web UI for the two existing import backends (issue #281: the routes + * shipped without a surface). Two clearly-separated controls: + * + * 1. Apple Health `export.zip` — multipart POST to + * `/api/import/apple-health-export` (field `file`), then poll + * `GET /api/import/apple-health-export/[jobId]/status` on a + * `refetchInterval` while the job runs, stopping on a terminal + * state (`done` / `failed`). The upload streams server-side so a + * multi-GB archive never buffers in the browser beyond the POST. + * Idempotent on the file's SHA-256 — re-uploading the same archive + * merges rather than duplicating. + * + * 2. Generic JSON import — upload a `.json` file OR paste JSON, + * validated client-side for parseability, POSTed to `/api/import`. + * A "Download example" button mints a small valid example as a Blob + * (no static asset). Returns `{ measurements, moodEntries, skipped }` + * counts. + * + * Both controls surface rate-limit (429) and error states cleanly and + * announce progress to assistive tech via `aria-live` regions. + */ + +import { useCallback, useId, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + AlertCircle, + CheckCircle2, + Download, + FileJson, + Loader2, + Upload, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; +import { Textarea } from "@/components/ui/textarea"; +import { queryKeys } from "@/lib/query-keys"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +// ─────────────────────────── Section wrapper ─────────────────────────── + +export function ImportPanel() { + const { t } = useTranslations(); + return ( +
+
+

+ {t("settings.sections.export.import.heading")} +

+

+ {t("settings.sections.export.import.description")} +

+
+
+ + +
+
+ ); +} + +// ─────────────────────────── Card shell ─────────────────────────── + +interface ImportCardShellProps { + testId: string; + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; + children: React.ReactNode; +} + +function ImportCardShell({ + testId, + icon: Icon, + title, + description, + children, +}: ImportCardShellProps) { + return ( +
+
+
+

{description}

+
{children}
+
+ ); +} + +// ─────────────────────────── Apple Health import ─────────────────────────── + +/** Terminal states the status poll stops on. */ +const TERMINAL_STATES: readonly string[] = ["done", "failed"]; + +interface JobStatus { + jobId: string; + status: string; + progress: { + currentPhase?: string; + recordsRead?: number; + rowsUpserted?: number; + percent?: number | null; + } | null; + result: { + totals?: { recordsRead?: number; rowsUpserted?: number }; + clinical?: { skipped?: number }; + } | null; + failureReason: string | null; +} + +function AppleHealthImportCard() { + const { t } = useTranslations(); + const fileInputRef = useRef(null); + const [jobId, setJobId] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [dragActive, setDragActive] = useState(false); + const dropDescId = useId(); + + const statusQuery = useQuery({ + queryKey: queryKeys.importJobStatus(jobId ?? "none"), + enabled: jobId !== null, + refetchInterval: (query) => { + const data = query.state.data as JobStatus | undefined; + if (data && TERMINAL_STATES.includes(data.status)) return false; + return 2000; + }, + queryFn: async (): Promise => { + const res = await fetch( + `/api/import/apple-health-export/${jobId}/status`, + { credentials: "include" }, + ); + if (!res.ok) { + throw new Error(`status-${res.status}`); + } + return (await res.json()).data as JobStatus; + }, + }); + + const upload = useCallback( + async (file: File) => { + setUploadError(null); + setUploading(true); + setJobId(null); + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/import/apple-health-export", { + method: "POST", + credentials: "include", + body: form, + }); + if (res.status === 429) { + setUploadError(t("settings.sections.export.import.appleHealth.rateLimited")); + return; + } + if (res.status === 413) { + setUploadError(t("settings.sections.export.import.appleHealth.tooLarge")); + return; + } + if (!res.ok) { + setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed")); + return; + } + const body = (await res.json()).data as { + jobId: string; + status: string; + }; + if (!body?.jobId) { + setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed")); + return; + } + setJobId(body.jobId); + } catch { + setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed")); + } finally { + setUploading(false); + } + }, + [t], + ); + + function onFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) void upload(file); + // Reset so picking the same file twice re-fires the change event. + e.target.value = ""; + } + + function onDrop(e: React.DragEvent) { + e.preventDefault(); + setDragActive(false); + const file = e.dataTransfer.files?.[0]; + if (file) void upload(file); + } + + const status = statusQuery.data ?? null; + const isRunning = + status !== null && !TERMINAL_STATES.includes(status.status); + const isDone = status?.status === "done"; + const isFailed = status?.status === "failed"; + const busy = uploading || isRunning; + const percent = status?.progress?.percent ?? null; + + return ( + + {/* Keyboard-operable drop area. The hidden file input does the + actual file selection; the div forwards Enter/Space to it. */} +
!busy && fileInputRef.current?.click()} + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === " ") && !busy) { + e.preventDefault(); + fileInputRef.current?.click(); + } + }} + onDragOver={(e) => { + e.preventDefault(); + if (!busy) setDragActive(true); + }} + onDragLeave={() => setDragActive(false)} + onDrop={(e) => !busy && onDrop(e)} + className={cn( + "border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none", + dragActive && "border-primary bg-primary/5", + busy && "pointer-events-none opacity-60", + )} + > +
+ + +

+ {t("settings.sections.export.import.appleHealth.idempotencyNote")} +

+ + {/* Live progress / outcome — announced to assistive tech. */} +
+ {uploading && ( +

+ + {t("settings.sections.export.import.appleHealth.uploading")} +

+ )} + {isRunning && ( +
+

+ + {t( + `settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`, + )} +

+ {typeof percent === "number" && ( + + )} + {typeof status?.progress?.rowsUpserted === "number" && ( +

+ {t("settings.sections.export.import.appleHealth.rowsImported", { + count: status.progress.rowsUpserted, + })} +

+ )} +
+ )} + {isDone && ( +
+
+ )} + {(isFailed || uploadError) && ( +

+

+ )} +
+ +
+ +
+
+ ); +} + +// ─────────────────────────── Generic JSON import ─────────────────────────── + +/** + * Small valid example payload, minted as a downloadable Blob by the + * "Download example" button and used by the docs. Exported so a test can + * assert it stays a valid import body — the button and the route schema + * must never drift. + */ +export const EXAMPLE_IMPORT = { + measurements: [ + { + type: "WEIGHT", + value: 80.5, + unit: "kg", + measuredAt: "2026-05-01T08:00:00.000Z", + source: "manual", + notes: "morning", + }, + { + type: "BLOOD_PRESSURE_SYS", + value: 120, + unit: "mmHg", + measuredAt: "2026-05-01T08:05:00.000Z", + }, + { + type: "BLOOD_PRESSURE_DIA", + value: 80, + unit: "mmHg", + measuredAt: "2026-05-01T08:05:00.000Z", + }, + ], + moodEntries: [ + { + date: "2026-05-01", + mood: "GUT", + score: 4, + tags: "work,exercise", + }, + ], +}; + +interface JsonImportResult { + measurements: number; + moodEntries: number; + skipped: number; +} + +/** + * Client-side parse guard for the JSON-import textarea. Returns the + * parsed value when the text is valid JSON, otherwise a failure marker — + * we never POST an unparseable body. Exported for unit testing so the + * guard is covered without a browser. + */ +export function parseImportJson( + text: string, +): { ok: true; value: unknown } | { ok: false } { + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false }; + } +} + +function JsonImportCard() { + const { t } = useTranslations(); + const fileInputRef = useRef(null); + const [text, setText] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const textareaId = useId(); + + async function onFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setError(null); + setResult(null); + try { + const content = await file.text(); + setText(content); + } catch { + setError(t("settings.sections.export.import.json.readFailed")); + } + } + + function downloadExample() { + const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "healthlog-import-example.json"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + async function handleImport() { + setError(null); + setResult(null); + const parsed = parseImportJson(text); + if (!parsed.ok) { + setError(t("settings.sections.export.import.json.invalidJson")); + return; + } + const payload = parsed.value; + setBusy(true); + try { + const res = await fetch("/api/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + }); + if (res.status === 429) { + setError(t("settings.sections.export.import.json.rateLimited")); + return; + } + if (res.status === 422) { + const body = await res.json().catch(() => null); + setError( + body?.error ?? t("settings.sections.export.import.json.invalidPayload"), + ); + return; + } + if (!res.ok) { + setError(t("settings.sections.export.import.json.failed")); + return; + } + const data = (await res.json()).data as JsonImportResult; + setResult({ + measurements: data?.measurements ?? 0, + moodEntries: data?.moodEntries ?? 0, + skipped: data?.skipped ?? 0, + }); + } catch { + setError(t("settings.sections.export.import.json.failed")); + } finally { + setBusy(false); + } + } + + return ( + +
+ +