mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge the Apple Health import end-to-end journey
This commit is contained in:
commit
1dc4f70fed
6 files changed
+421
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user