diff --git a/e2e/README.md b/e2e/README.md index 4cf2a7ef8..202dd3dea 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -14,6 +14,7 @@ The Playwright config (`../playwright.config.ts`) builds the app (`next build`) - **`setup/`** — `test.ts` (the suite's `test` + `expect` entry point), `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers). - **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`. +- **`fixtures/`** — synthetic payloads a journey uploads, built in code rather than committed as blobs so the content stays reviewable (`apple-health-export.ts`). - **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set. ## Conventions diff --git a/e2e/apple-health-import.spec.ts b/e2e/apple-health-import.spec.ts new file mode 100644 index 000000000..0ef9d061e --- /dev/null +++ b/e2e/apple-health-import.spec.ts @@ -0,0 +1,197 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + appleHealthExportZip, + archiveWithoutExportXml, + FIXTURE_READINGS, + MISSING_EXPORT_XML_REASON, +} from "./fixtures/apple-health-export"; + +/** + * Apple Health `export.zip` → readings on the measurements list. + * + * The one journey that crosses every layer the import owns: a person drops + * an archive on `/settings/export`, the kick-off route streams it to disk and + * queues `apple-health-import-v2`, the pg-boss worker in the same process + * unpacks it and streams the XML into `Measurement` rows, the card's status + * poll turns the spinner into a written outcome, and the rows show up on + * `/measurements` carrying the units the parser resolved. Every one of those + * steps has unit coverage; none of it proves the chain holds when a real + * browser drives it against a real queue and a real database. + * + * The archives are synthetic and built in memory — see + * `fixtures/apple-health-export.ts`. + */ +type FixtureReading = (typeof FIXTURE_READINGS)[keyof typeof FIXTURE_READINGS]; + +/** + * Serial, because the journey mutates the one shared account and the refusal + * control reads a count off it. Two of these tests in flight at once would + * also spend the kick-off route's three-uploads-per-minute budget on each + * other rather than on what they are testing. + */ +test.describe.configure({ mode: "serial" }); + +/** + * The import is the slowest single action in the browser suite: an upload, + * a queue hop, an unpack, a parse, and a status poll that ticks every two + * seconds. The default 30 s budget covers none of that on a loaded runner. + */ +test.setTimeout(180_000); + +/** + * Clear this account's upload bucket before each upload. + * + * `POST /api/import/apple-health-export` allows three uploads per user per + * minute, which is the right production number and exactly one too few for a + * spec that uploads twice and is expected to survive `--repeat-each`. The + * fourth upload inside the window would come back 429 and the card would + * render the rate-limit copy — a red run that says nothing about the import. + * + * Reaching for Postgres directly is the same channel `global-setup.ts` uses + * to seed and to clear the auth buckets; the alternative is to sit out a + * 60-second window, which is a sleep wearing a poll's clothing. + */ +async function clearImportUploadBucket(): Promise { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error( + "DATABASE_URL is unset — the import journey needs the same database the app is running against.", + ); + } + const { default: pg } = await import("pg"); + const client = new pg.Client({ connectionString }); + await client.connect(); + try { + await client.query( + `DELETE FROM rate_limits WHERE key LIKE 'import:apple-health:%'`, + ); + } finally { + await client.end(); + } +} + +/** Open the import surface and hand back the Apple Health card. */ +async function openImportCard(page: Page) { + await page.goto("/settings/export", { waitUntil: "domcontentloaded" }); + const card = page.getByTestId("import-card-apple-health"); + await expect(card).toBeVisible({ timeout: 30_000 }); + return card; +} + +/** + * Hand the archive to the card's own file input, which is what a drop or a + * click on "Choose file" ends up doing. The upload itself then runs through + * the card's `fetch`, so the spec seeds nothing the page would not have sent. + */ +async function uploadArchive( + page: Page, + archive: Buffer, + name: string, +): Promise { + const card = await openImportCard(page); + await card.locator('input[type="file"]').setInputFiles({ + name, + mimeType: "application/zip", + buffer: archive, + }); +} + +/** How many readings this account holds from an Apple Health import. */ +async function appleHealthRowCount(page: Page): Promise { + return page.evaluate(async () => { + const res = await fetch( + "/api/measurements?sourceEq=APPLE_HEALTH&limit=1&offset=0", + { credentials: "include" }, + ); + const body = (await res.json()) as { + data: { meta: { total: number } } | null; + }; + if (!body.data) throw new Error("measurements list returned no data"); + return body.data.meta.total; + }); +} + +/** + * Assert one imported reading on the measurements list, by the value and the + * unit the row carries rather than by the locale text it renders. + */ +async function expectReadingOnList( + page: Page, + reading: FixtureReading, +): Promise { + await page.goto(`/measurements?type=${reading.type}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.locator('[data-slot="measurement-rows"]')).toBeVisible({ + timeout: 30_000, + }); + const row = page.locator( + `[data-testid="measurement-row"][data-measurement-type="${reading.type}"]` + + `[data-measurement-value="${reading.value}"]`, + ); + await expect(row).toHaveCount(1); + await expect(row).toHaveAttribute("data-measurement-unit", reading.unit); +} + +test.describe("Apple Health export import", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("an uploaded export surfaces as readings with their units", async ({ + page, + }) => { + await clearImportUploadBucket(); + await uploadArchive(page, appleHealthExportZip(), "export.zip"); + + // The card polls the status route every two seconds and swaps the + // spinner for the written outcome on a terminal state. `data-outcome` + // is the outcome itself; the sentence beside it is translated copy. + const result = page.getByTestId("import-apple-health-result"); + await expect(result).toBeVisible({ timeout: 150_000 }); + await expect(result).toHaveAttribute("data-outcome", "success"); + + // The weight the archive wrote in kilograms, the blood-pressure pair it + // wrote in mmHg, and the step count it wrote as a bare count — each on + // the list, each carrying the unit the parser resolved for it. + await expectReadingOnList(page, FIXTURE_READINGS.weight); + await expectReadingOnList(page, FIXTURE_READINGS.systolic); + await expectReadingOnList(page, FIXTURE_READINGS.diastolic); + await expectReadingOnList(page, FIXTURE_READINGS.steps); + }); + + test("a distance written in kilometres is stored in metres", async ({ + page, + }) => { + test.skip( + true, + "issue #944 — the parser resolves a record's unit from the type map " + + "and ignores the `unit` attribute the record carries, so a distance " + + "written as 2.484 km lands as 2.484 m. The fix is on a branch that " + + "has not reached main; this is the contract it has to meet, kept " + + "here at full strength rather than relaxed to match today's value.", + ); + await expectReadingOnList(page, FIXTURE_READINGS.walkingDistance); + }); + + test("an archive without export.xml is refused and imports nothing", async ({ + page, + }) => { + await clearImportUploadBucket(); + await openImportCard(page); + const before = await appleHealthRowCount(page); + + await uploadArchive(page, archiveWithoutExportXml(), "not-an-export.zip"); + + // The worker's own words, surfaced verbatim by the card: the failure + // reason is honest English free text, not a code the UI translates. + const error = page.getByTestId("import-apple-health-error"); + await expect(error).toBeVisible({ timeout: 150_000 }); + await expect(error).toContainText(MISSING_EXPORT_XML_REASON); + + // And a refusal that still wrote something would be worse than no + // refusal at all. + expect(await appleHealthRowCount(page)).toBe(before); + }); +}); diff --git a/e2e/fixtures/apple-health-export.ts b/e2e/fixtures/apple-health-export.ts new file mode 100644 index 000000000..cb02484eb --- /dev/null +++ b/e2e/fixtures/apple-health-export.ts @@ -0,0 +1,188 @@ +/** + * Synthetic Apple Health `export.zip` fixtures for the import journey. + * + * The archives are built in memory rather than committed as binaries, for + * two reasons. The XML stays readable in the diff — a fixture whose content + * only exists inside a blob is a fixture nobody reviews — and the bytes stay + * deterministic, which the kick-off route's content-hash idempotency needs: + * a re-run of the same spec (`--repeat-each`) must resolve to the job the + * first run created instead of queueing a second import of the same export. + * + * The data is invented. No real reading, account, device or name appears + * here; every source is a fixture label and every timestamp is a fixed day + * in the past chosen so it cannot collide with anything the rest of the + * suite writes. + */ +import { crc32, deflateRawSync } from "node:zlib"; + +/** The calendar day (Europe/Berlin) every fixture record is stamped on. */ +export const FIXTURE_DAY = "2019-03-04"; + +/** + * What the archive claims, and what the measurements list must show for it. + * + * `value` is the number the row carries once the import has run, in the + * canonical DB unit — which is NOT always the unit the record was written + * in. That gap is the point of the walking-distance entry below. + */ +export const FIXTURE_READINGS = { + weight: { type: "WEIGHT", value: 74.2, unit: "kg" }, + systolic: { type: "BLOOD_PRESSURE_SYS", value: 118, unit: "mmHg" }, + diastolic: { type: "BLOOD_PRESSURE_DIA", value: 76, unit: "mmHg" }, + steps: { type: "ACTIVITY_STEPS", value: 8421, unit: "steps" }, + /** + * Written as `unit="km"`, stored in metres: 2.484 km is 2484 m. Issue #944 + * — the parser reads the mapping's unit and ignores the record's own, so + * today the row lands as 2.484 m. + */ + walkingDistance: { type: "WALKING_RUNNING_DISTANCE", value: 2484, unit: "m" }, +} as const; + +/** The refusal the extractor raises for an archive with no `export.xml`. */ +export const MISSING_EXPORT_XML_REASON = + "Archive is missing the `apple_health_export/export.xml` member"; + +const EXPORT_XML = ` +]> + + + + + + + + + + + + + + +`; + +interface ZipMember { + name: string; + payload: Buffer; +} + +/** + * Minimal ZIP writer — deflate-compressed members, no Zip64, fixed + * (zeroed) modification stamps so the same members always produce the same + * bytes. Mirrors the hand-built archives in + * `src/lib/import/__tests__/unzip-export-xml.test.ts`; the extractor under + * test reads the central directory, which is what this emits. + */ +function buildZip(members: readonly ZipMember[]): Buffer { + const locals: Buffer[] = []; + const central: Buffer[] = []; + let offset = 0; + + for (const member of members) { + const nameBuf = Buffer.from(member.name, "utf8"); + const compressed = deflateRawSync(member.payload); + const crc = crc32(member.payload); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); // version needed + localHeader.writeUInt16LE(0, 6); // flags + localHeader.writeUInt16LE(8, 8); // method: deflate + localHeader.writeUInt16LE(0, 10); // mtime + localHeader.writeUInt16LE(0, 12); // mdate + localHeader.writeUInt32LE(crc, 14); + localHeader.writeUInt32LE(compressed.length, 18); + localHeader.writeUInt32LE(member.payload.length, 22); + localHeader.writeUInt16LE(nameBuf.length, 26); + localHeader.writeUInt16LE(0, 28); // extra length + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); // version made by + centralHeader.writeUInt16LE(20, 6); // version needed + centralHeader.writeUInt16LE(0, 8); // flags + centralHeader.writeUInt16LE(8, 10); // method: deflate + centralHeader.writeUInt16LE(0, 12); // mtime + centralHeader.writeUInt16LE(0, 14); // mdate + centralHeader.writeUInt32LE(crc, 16); + centralHeader.writeUInt32LE(compressed.length, 20); + centralHeader.writeUInt32LE(member.payload.length, 24); + centralHeader.writeUInt16LE(nameBuf.length, 28); + centralHeader.writeUInt16LE(0, 30); // extra + centralHeader.writeUInt16LE(0, 32); // comment + centralHeader.writeUInt16LE(0, 34); // disk number + centralHeader.writeUInt16LE(0, 36); // internal attrs + centralHeader.writeUInt32LE(0, 38); // external attrs + centralHeader.writeUInt32LE(offset, 42); + + locals.push(localHeader, nameBuf, compressed); + central.push(centralHeader, nameBuf); + offset += localHeader.length + nameBuf.length + compressed.length; + } + + const centralBuf = Buffer.concat(central); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); // disk + eocd.writeUInt16LE(0, 6); // disk with central directory + eocd.writeUInt16LE(members.length, 8); + eocd.writeUInt16LE(members.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(offset, 16); + eocd.writeUInt16LE(0, 20); // comment length + + return Buffer.concat([...locals, centralBuf, eocd]); +} + +/** A well-formed export archive carrying the five fixture records. */ +export function appleHealthExportZip(): Buffer { + return buildZip([ + { + name: "apple_health_export/export.xml", + payload: Buffer.from(EXPORT_XML, "utf8"), + }, + ]); +} + +/** + * An archive that is a valid ZIP and carries no `export.xml` — the shape a + * person produces by zipping the wrong folder. The import must refuse it and + * write nothing. + */ +export function archiveWithoutExportXml(): Buffer { + return buildZip([ + { + name: "apple_health_export/export_cda.xml", + payload: Buffer.from("\n", "utf8"), + }, + ]); +} diff --git a/playwright.config.ts b/playwright.config.ts index 928caf004..e71dcacad 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -154,6 +154,15 @@ export default defineConfig({ "vaccinations.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", + // The Apple Health import journey uploads an archive into the one + // shared account and its refusal control asserts that the account's + // Apple-Health row count did not move. A second project running the + // same journey in parallel would import beside it and flip that + // verdict — and both would be spending the kick-off route's + // three-uploads-per-minute budget on each other. It proves a flow + // through stable data attributes, not a mobile layout, so it runs in + // one project. + "apple-health-import.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 4ffd2d8be..dbe48c210 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -343,6 +343,29 @@ export function MeasurementList({ }, [unitDisplay, glucoseUnit], ); + /** + * Machine-readable mirror of what the row renders — the same number and + * the same unit, before `Intl` turns them into locale text. + * + * The rendered cell is the only place a reading's value and unit appear + * together, and it appears there as "8.421 steps" or "8,421 steps" + * depending on who is looking. A browser test that has to assert a stored + * unit therefore either pins a locale or matches a loose number, and both + * describe the formatter rather than the reading. These attributes carry + * the reading itself, and the desktop table and the mobile list emit the + * same set, so an assertion holds at either viewport. + */ + const rowIdentityAttrs = useCallback( + (m: Measurement, isGrouped: boolean) => ({ + "data-testid": "measurement-row", + "data-measurement-type": m.type, + "data-measurement-value": String( + isGrouped ? m.value : rowDisplay(m).value, + ), + "data-measurement-unit": isGrouped ? m.unit : rowDisplay(m).unit, + }), + [rowDisplay], + ); // v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH // render (only CSS `display` hid one), so a dense cumulative (up to ~5000 // rows) or sleep page built every row subtree twice — double the DOM, double @@ -1161,6 +1184,7 @@ export function MeasurementList({ {/* Grouped/synthetic rows aren't individually @@ -1358,6 +1382,7 @@ export function MeasurementList({
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 4bbf87135..344299c81 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -499,6 +499,7 @@ export function AppleHealthImportCard() { )} {(isFailed || uploadError) && (