Files
HealthLog/src/components/settings/__tests__/import-panel.test.tsx
T
Marc-André Bombeck d26bbca52a fix(import): translate machine-readable import failure reasons
A user whose import died to a worker restart saw the literal string
interrupted_by_restart in the import card — the reconcile writes it as
a machine-readable code, and the card echoed it raw. Classify the two
coded reasons (interrupted_by_restart, the insufficient_memory prefix
from the memory preflight) onto translated, actionable copy across all
six locales; the copy tells the user their data is safe and to upload
the export again, which the re-enabled card already supports. Honest
free-text reasons keep passing through verbatim. Refs #775.
2026-08-13 06:16:44 +02:00

235 lines
8.6 KiB
TypeScript

/**
* v1.15.7 — Settings → Export & Import → `<ImportPanel>` 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 type { Locale } from "@/lib/i18n/config";
import {
AppleHealthEstimateWarning,
appleHealthFailureKind,
} from "../import-panel/apple-health-import-card";
import { I18nProvider } from "@/lib/i18n/context";
import {
ImportPanel,
EXAMPLE_IMPORT,
EXAMPLE_IMPORT_DOWNLOAD_HREF,
EXAMPLE_CSV,
parseImportJson,
} from "../import-panel";
import { parseCsvMeasurements } from "@/lib/import/csv-measurements";
function render(node: React.ReactElement, locale: Locale = "en") {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<ImportPanel> — SSR smoke", () => {
it("renders the import heading and both cards", () => {
const html = render(<ImportPanel />);
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"');
expect(html).toContain('data-testid="import-card-csv"');
// 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(<ImportPanel />);
for (const id of [
"import-action-apple-health",
"import-json-textarea",
"import-json-choose-file",
"import-json-download-example",
"import-action-json",
"import-csv-textarea",
"import-csv-choose-file",
"import-csv-download-example",
"import-csv-preview",
"import-action-csv",
]) {
expect(html).toContain(`data-testid="${id}"`);
}
});
it("gives the Apple Health drop area a keyboard-operable role", () => {
const html = render(<ImportPanel />);
// 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(<ImportPanel />);
// 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(<ImportPanel />, "de");
expect(html).toContain("Import");
expect(html).not.toContain("settings.sections.export.import.");
});
});
describe("<AppleHealthEstimateWarning>", () => {
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
"renders localized estimate disclosure for %s",
(locale) => {
const html = render(<AppleHealthEstimateWarning days={3} />, locale);
expect(html).toContain('data-testid="apple-health-estimate-warning"');
expect(html).toContain("3");
expect(html).not.toContain(
"settings.sections.export.import.appleHealth.estimateWarning",
);
},
);
});
// (issue #775) — the worker writes two machine-readable failure
// reasons (the reconcile's `interrupted_by_restart` code and the memory
// preflight's `insufficient_memory` prefix). The card must translate
// them instead of echoing a raw code at the user; honest free-text
// reasons keep passing through verbatim.
describe("appleHealthFailureKind", () => {
it("maps the reconcile code onto translated copy", () => {
expect(appleHealthFailureKind("interrupted_by_restart")).toBe(
"interrupted",
);
});
it("maps the memory-preflight refusal onto translated copy", () => {
expect(
appleHealthFailureKind(
"insufficient_memory: export.xml declares 8192 MiB uncompressed; " +
"parsing it needs roughly 320 MiB of heap but the Node.js heap " +
"limit is 256 MiB.",
),
).toBe("insufficientMemory");
});
it("passes honest free-text reasons through verbatim", () => {
expect(
appleHealthFailureKind("Import staging file is no longer available"),
).toBe("raw");
});
it("returns null when the job has no failure reason", () => {
expect(appleHealthFailureKind(null)).toBeNull();
expect(appleHealthFailureKind("")).toBeNull();
});
});
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,
);
});
it("exposes a self-contained, inert JSON download target", () => {
expect(EXAMPLE_IMPORT_DOWNLOAD_HREF).toMatch(
/^data:application\/json;charset=utf-8,/,
);
expect(EXAMPLE_IMPORT_DOWNLOAD_HREF).not.toMatch(
/(?:javascript:|blob:|<script)/i,
);
const encoded = EXAMPLE_IMPORT_DOWNLOAD_HREF.split(",", 2)[1];
expect(encoded).toBeTruthy();
expect(JSON.parse(decodeURIComponent(encoded!))).toEqual(EXAMPLE_IMPORT);
const html = render(<ImportPanel />);
expect(html).toContain('download="healthlog-import-example.json"');
expect(html).toContain('href="data:application/json;charset=utf-8,');
});
});
describe("EXAMPLE_CSV", () => {
it("parses entirely to ok rows (example must not drift from the schema)", () => {
// Pin the clock well past the fixture timestamps so the entry-instant
// bound never rejects the example as future-dated.
const out = parseCsvMeasurements(EXAMPLE_CSV, {
now: new Date("2026-06-01T00:00:00Z").getTime(),
});
expect(out.fatal).toBeUndefined();
expect(out.rows.length).toBeGreaterThan(0);
expect(out.rows.every((r) => r.status === "ok")).toBe(true);
});
it("carries a contextless glucose reading, the shape a sensor export has", () => {
const out = parseCsvMeasurements(EXAMPLE_CSV, {
now: new Date("2026-06-01T00:00:00Z").getTime(),
});
const glucose = out.rows
.map((r) => r.row)
.filter((row) => row?.type === "BLOOD_GLUCOSE");
expect(glucose.length).toBeGreaterThanOrEqual(2);
expect(glucose.some((row) => row?.glucoseContext === undefined)).toBe(true);
expect(glucose.some((row) => row?.glucoseContext === "FASTING")).toBe(true);
});
});
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);
});
});