Merge the Apple Health import end-to-end journey

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 01:09:27 +02:00
commit 1dc4f70fed
6 files changed
+421 -4

No files matched your search

+1
View File
@@ -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
+197
View File
@@ -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<void> {
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<void> {
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<number> {
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<void> {
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);
});
});
+188
View File
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE HealthData [<!ELEMENT HealthData (Record|Workout|ClinicalRecord)*>]>
<HealthData locale="en_US">
<ExportDate value="${FIXTURE_DAY} 21:00:00 +0100"/>
<Me HKCharacteristicTypeIdentifierDateOfBirth="1980-01-01"/>
<Record type="HKQuantityTypeIdentifierBodyMass"
unit="kg"
startDate="${FIXTURE_DAY} 07:15:00 +0100"
endDate="${FIXTURE_DAY} 07:15:00 +0100"
value="${FIXTURE_READINGS.weight.value}"
sourceName="Fixture Scale"
sourceVersion="1.0"/>
<Record type="HKQuantityTypeIdentifierBloodPressureSystolic"
unit="mmHg"
startDate="${FIXTURE_DAY} 07:20:00 +0100"
endDate="${FIXTURE_DAY} 07:20:00 +0100"
value="${FIXTURE_READINGS.systolic.value}"
sourceName="Fixture Cuff"
sourceVersion="1.0"/>
<Record type="HKQuantityTypeIdentifierBloodPressureDiastolic"
unit="mmHg"
startDate="${FIXTURE_DAY} 07:20:00 +0100"
endDate="${FIXTURE_DAY} 07:20:00 +0100"
value="${FIXTURE_READINGS.diastolic.value}"
sourceName="Fixture Cuff"
sourceVersion="1.0"/>
<Record type="HKQuantityTypeIdentifierStepCount"
unit="count"
startDate="${FIXTURE_DAY} 09:00:00 +0100"
endDate="${FIXTURE_DAY} 09:30:00 +0100"
value="${FIXTURE_READINGS.steps.value}"
sourceName="Fixture Phone"
sourceVersion="1.0"/>
<Record type="HKQuantityTypeIdentifierDistanceWalkingRunning"
unit="km"
startDate="${FIXTURE_DAY} 09:00:00 +0100"
endDate="${FIXTURE_DAY} 09:30:00 +0100"
value="2.484"
sourceName="Fixture Phone"
sourceVersion="1.0"/>
</HealthData>
`;
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("<ClinicalDocument/>\n", "utf8"),
},
]);
}
+9
View File
@@ -162,6 +162,15 @@ export default defineConfig({
"glucose-journey.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
@@ -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
@@ -1168,8 +1191,7 @@ export function MeasurementList({
// without matching translated text that also
// renders at a different size on the card list
// below.
data-testid="measurement-row"
data-measurement-type={m.type}
{...rowIdentityAttrs(m, isGrouped)}
data-display-unit={
isGrouped ? m.unit : rowDisplay(m).unit
}
@@ -1374,8 +1396,7 @@ export function MeasurementList({
key={m.id}
data-state={isSelected ? "selected" : undefined}
// Same addressing contract as the desktop row above.
data-testid="measurement-row"
data-measurement-type={m.type}
{...rowIdentityAttrs(m, isGrouped)}
data-display-unit={
isGrouped ? m.unit : rowDisplay(m).unit
}
@@ -499,6 +499,7 @@ export function AppleHealthImportCard() {
)}
{(isFailed || uploadError) && (
<p
data-testid="import-apple-health-error"
role="alert"
className="text-destructive flex items-start gap-2 text-sm"
>