From 09072c9f62dff886cd57c33dc97ee986d56cb492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:36:20 +0200 Subject: [PATCH] test(healthkit): pin dose arrivals, the background date and migration 0401 over enum rows --- .../__tests__/apple-health-card.test.tsx | 46 +++++ .../healthkit-type-sync-migration.test.ts | 189 ++++++++++++++++++ tests/integration/healthkit-type-sync.test.ts | 179 +++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 tests/integration/healthkit-type-sync-migration.test.ts diff --git a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index 198518449..1ae787c63 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -426,6 +426,52 @@ describe(" — per-type arrivals (#1173)", () => { expect(out).toContain("Last new value 3 d ago"); }); + // v1.42.4 — opening the app to look starts a foreground sync; the earlier + // background arrival must stay visible beside it. + it("keeps the last background arrival visible after a later foreground one", () => { + const out = html({ + ...base, + lastReceivedAt: hoursAgo(1), + lastTrigger: "foreground", + lastNewSampleAt: hoursAgo(1), + lastBackgroundAt: hoursAgo(5), + }); + expect(out).toContain('data-trigger="foreground"'); + expect(out).toContain("Last in background 5 h ago"); + }); + + it("does not repeat the background date when the last arrival was it", () => { + const out = html({ + ...base, + lastReceivedAt: hoursAgo(1), + lastTrigger: "background", + lastNewSampleAt: hoursAgo(1), + lastBackgroundAt: hoursAgo(1), + }); + expect(out).not.toContain("Last in background"); + }); + + it("names the medication dose row in the freshness list", () => { + statusPayload = { + lastSyncedAt: hoursAgo(1), + health: healthFixture(), + metricFreshness: [ + { + type: "MEDICATION_DOSE", + lastSeenAt: hoursAgo(3), + stale: false, + lastReceivedAt: hoursAgo(1), + lastTrigger: "foreground", + lastNewSampleAt: hoursAgo(1), + lastBackgroundAt: hoursAgo(6), + }, + ], + }; + const out = render(); + expect(out).toContain("Medication doses"); + expect(out).toContain("Last in background"); + }); + it("says a type has not arrived through live sync yet", () => { const out = html({ ...base, diff --git a/tests/integration/healthkit-type-sync-migration.test.ts b/tests/integration/healthkit-type-sync-migration.test.ts new file mode 100644 index 000000000..66e7638d3 --- /dev/null +++ b/tests/integration/healthkit-type-sync-migration.test.ts @@ -0,0 +1,189 @@ +/** + * Migration 0401 over a ledger that already holds enum-typed rows. + * + * `healthkit_type_syncs.type` moves from the `measurement_type` enum to text + * so the ledger can hold `MEDICATION_DOSE`, and `last_background_at` arrives + * beside it. The suite has already applied every migration, so this puts the + * table back into its 0384 shape, seeds rows the way production holds them, + * applies the 0401 file as shipped, and checks that every row kept its key and + * its dates, that a row whose latest arrival was a background one starts with + * that date, that the primary key still refuses a second row for the same + * (user, type), and that the writer can now record a dose. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { recordHealthKitTypeArrivals } from "@/lib/integrations/healthkit-type-sync"; + +const prisma = getPrismaClient(); +const USER = "healthkit-type-sync-migration"; + +const MIGRATION = readFileSync( + resolve( + process.cwd(), + "prisma", + "migrations", + "0401_healthkit_type_sync_text_and_background", + "migration.sql", + ), + "utf8", +); + +function steps(): string[] { + return MIGRATION.split("\n") + .filter((line) => !line.trimStart().startsWith("--")) + .join("\n") + .split(";") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +async function runMigration(): Promise { + await prisma.$transaction(async (tx) => { + for (const step of steps()) await tx.$executeRawUnsafe(step); + }); +} + +async function typeColumn(): Promise<{ data_type: string; udt_name: string }> { + const rows = await prisma.$queryRaw< + Array<{ data_type: string; udt_name: string }> + >` + SELECT data_type, udt_name FROM information_schema.columns + WHERE table_name = 'healthkit_type_syncs' AND column_name = 'type' + `; + return rows[0]; +} + +const RECEIVED = { + WEIGHT: "2026-09-01 06:00:00", + PULSE: "2026-09-02 07:30:00", + RESPIRATORY_RATE: "2026-09-03 08:45:00", +} as const; + +type Row = { + type: string; + last_received_at: Date; + last_trigger: string | null; + last_new_sample_at: Date | null; + last_background_at: Date | null; +}; + +const readRows = () => + prisma.$queryRaw` + SELECT "type", "last_received_at", "last_trigger", "last_new_sample_at", + "last_background_at" + FROM "healthkit_type_syncs" WHERE "user_id" = ${USER} ORDER BY "type" + `; + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, timezone: "UTC" }, + }); + // Back to the 0384 shape: the enum column, no background date. + await prisma.$executeRawUnsafe( + `ALTER TABLE "healthkit_type_syncs" DROP COLUMN "last_background_at"`, + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "healthkit_type_syncs" ALTER COLUMN "type" SET DATA TYPE "measurement_type" USING "type"::"measurement_type"`, + ); + await prisma.$executeRawUnsafe(` + INSERT INTO "healthkit_type_syncs" + ("user_id", "type", "last_received_at", "last_trigger", "last_new_sample_at") + VALUES + ('${USER}', 'WEIGHT', '${RECEIVED.WEIGHT}', 'background', '${RECEIVED.WEIGHT}'), + ('${USER}', 'PULSE', '${RECEIVED.PULSE}', 'foreground', NULL), + ('${USER}', 'RESPIRATORY_RATE', '${RECEIVED.RESPIRATORY_RATE}', NULL, '${RECEIVED.RESPIRATORY_RATE}') + `); +}); + +afterAll(async () => { + // Leave the table in the shipped shape for the files that run after this one, + // whatever happened above. + const column = await typeColumn(); + if (column.udt_name === "measurement_type") await runMigration(); +}); + +describe("migration 0401 over enum-typed ledger rows", () => { + it("starts from the enum column", async () => { + expect((await typeColumn()).udt_name).toBe("measurement_type"); + }); + + it("keeps every row, its key and its dates, and seeds the background date", async () => { + await runMigration(); + + expect(await typeColumn()).toEqual({ data_type: "text", udt_name: "text" }); + const rows = await readRows(); + expect(rows.map((r) => r.type)).toEqual([ + "PULSE", + "RESPIRATORY_RATE", + "WEIGHT", + ]); + const byType = Object.fromEntries(rows.map((r) => [r.type, r])); + for (const [type, at] of Object.entries(RECEIVED)) { + expect(byType[type].last_received_at.toISOString()).toBe( + `${at.replace(" ", "T")}.000Z`, + ); + } + expect(byType.WEIGHT.last_trigger).toBe("background"); + expect(byType.WEIGHT.last_new_sample_at?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", + ); + expect(byType.PULSE.last_new_sample_at).toBeNull(); + // Only a row whose latest arrival was a background one knows a date. + expect(byType.WEIGHT.last_background_at?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", + ); + expect(byType.PULSE.last_background_at).toBeNull(); + expect(byType.RESPIRATORY_RATE.last_background_at).toBeNull(); + }); + + it("keeps the primary key on (user, type)", async () => { + const pkey = await prisma.$queryRaw>` + SELECT indexdef FROM pg_indexes + WHERE tablename = 'healthkit_type_syncs' + AND indexname = 'healthkit_type_syncs_pkey' + `; + expect(pkey[0]?.indexdef).toContain("(user_id, type)"); + await expect( + prisma.$executeRawUnsafe( + `INSERT INTO "healthkit_type_syncs" ("user_id", "type", "last_received_at") VALUES ('${USER}', 'WEIGHT', now())`, + ), + ).rejects.toThrow(); + }); + + it("lets the writer upsert a measurement type and record a dose", async () => { + const at = new Date("2026-10-01T09:00:00.000Z"); + await recordHealthKitTypeArrivals(prisma, { + userId: USER, + counts: new Map([ + ["WEIGHT", { accepted: 1, newSamples: 0 }], + ["MEDICATION_DOSE", { accepted: 2, newSamples: 2 }], + ]), + trigger: "foreground", + at, + }); + const rows = await prisma.healthKitTypeSync.findMany({ + where: { userId: USER }, + orderBy: { type: "asc" }, + }); + expect(rows.map((r) => r.type)).toEqual([ + "MEDICATION_DOSE", + "PULSE", + "RESPIRATORY_RATE", + "WEIGHT", + ]); + const weight = rows.find((r) => r.type === "WEIGHT")!; + expect(weight.lastReceivedAt.toISOString()).toBe(at.toISOString()); + expect(weight.lastTrigger).toBe("foreground"); + // The foreground arrival leaves the seeded background date standing. + expect(weight.lastBackgroundAt?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", + ); + const dose = rows.find((r) => r.type === "MEDICATION_DOSE")!; + expect(dose.lastNewSampleAt?.toISOString()).toBe(at.toISOString()); + expect(dose.lastBackgroundAt).toBeNull(); + }); +}); diff --git a/tests/integration/healthkit-type-sync.test.ts b/tests/integration/healthkit-type-sync.test.ts index 7074a5e5b..b495e402d 100644 --- a/tests/integration/healthkit-type-sync.test.ts +++ b/tests/integration/healthkit-type-sync.test.ts @@ -300,3 +300,182 @@ describe("per-type arrival ledger (#1173)", () => { }); }); }); + +async function seedAppleMedication(): Promise { + const med = await getPrismaClient().medication.create({ + data: { + userId: USER_ID, + name: "Magnesium", + dose: "400mg", + active: true, + // Dose events from Apple Health only target a mirrored medication. + externalSource: "APPLE_HEALTH", + externalId: "hk-medication-type-sync", + }, + }); + return med.id; +} + +async function postDoses(body: Record): Promise { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + return POST( + new NextRequest("http://localhost/api/medications/intake/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function appleDose(medicationId: string, takenAt: string, externalId: string) { + return { + medicationId, + scheduledFor: takenAt, + takenAt, + source: "APPLE_HEALTH", + externalId, + }; +} + +type FreshnessRow = { + type: string; + lastSeenAt: string; + stale: boolean; + lastReceivedAt?: string | null; + lastTrigger?: string | null; + lastNewSampleAt?: string | null; + lastBackgroundAt?: string | null; +}; + +async function readFreshness(): Promise> { + const { GET } = await import("@/app/api/integrations/healthkit/route"); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/integrations/healthkit"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { metricFreshness: FreshnessRow[] }; + }; + return Object.fromEntries( + body.data.metricFreshness.map((row) => [row.type, row]), + ); +} + +const D1 = "2026-10-02T08:00:00.000Z"; +const D2 = "2026-10-03T08:00:00.000Z"; + +describe("Apple Health dose arrivals and the last background arrival (v1.42.4)", () => { + it("serves a dose sent in the background as the MEDICATION_DOSE entry", async () => { + await armSession(); + const medicationId = await seedAppleMedication(); + const res = await postDoses({ + syncTrigger: "background", + entries: [appleDose(medicationId, D1, "hk-dose-1")], + }); + expect(res.status).toBe(200); + + const dose = (await readFreshness()).MEDICATION_DOSE; + expect(dose).toMatchObject({ + lastSeenAt: D1, + stale: false, + lastTrigger: "background", + }); + expect(dose.lastReceivedAt).toEqual(expect.any(String)); + expect(dose.lastBackgroundAt).toBe(dose.lastReceivedAt); + expect(dose.lastNewSampleAt).toBe(dose.lastReceivedAt); + expect( + Math.abs(Date.parse(dose.lastReceivedAt!) - Date.now()), + ).toBeLessThan(60_000); + }); + + it("keeps the background date when a later foreground batch replays the dose", async () => { + await armSession(); + const medicationId = await seedAppleMedication(); + const first = appleDose(medicationId, D1, "hk-dose-1"); + await postDoses({ syncTrigger: "background", entries: [first] }); + const before = (await readFreshness()).MEDICATION_DOSE; + + await new Promise((resolve) => setTimeout(resolve, 20)); + const res = await postDoses({ + syncTrigger: "foreground", + entries: [first, appleDose(medicationId, D2, "hk-dose-2")], + }); + const body = (await res.json()) as { + data: { entries: Array<{ status: string }> }; + }; + expect(body.data.entries.map((e) => e.status)).toEqual([ + "duplicate", + "inserted", + ]); + + const after = (await readFreshness()).MEDICATION_DOSE; + expect(after.lastTrigger).toBe("foreground"); + expect(Date.parse(after.lastReceivedAt!)).toBeGreaterThan( + Date.parse(before.lastReceivedAt!), + ); + expect(after.lastBackgroundAt).toBe(before.lastBackgroundAt); + expect(after.lastSeenAt).toBe(D2); + }); + + it("records no dose arrival for a dose taken in the app", async () => { + await armSession(); + const medicationId = await seedAppleMedication(); + // The mirror gate refuses non-Apple entries on a mirrored medication only + // for APPLE_HEALTH entries; a plain entry lands as an app dose. + const res = await postDoses({ + syncTrigger: "background", + entries: [{ medicationId, scheduledFor: D1, takenAt: D1 }], + }); + expect(res.status).toBe(200); + expect( + await getPrismaClient().healthKitTypeSync.count({ + where: { userId: USER_ID }, + }), + ).toBe(0); + expect((await readFreshness()).MEDICATION_DOSE).toBeUndefined(); + }); + + it("keeps a measurement type's background date across a later foreground batch", async () => { + await armSession(); + await postBatch({ + syncTrigger: "background", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 70, "kg", T1, "uuid-w1"), + ], + }); + const background = + await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "WEIGHT" } }, + }); + expect(background.lastBackgroundAt?.toISOString()).toBe( + background.lastReceivedAt.toISOString(), + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + await postBatch({ + syncTrigger: "foreground", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 71, "kg", T2, "uuid-w2"), + ], + }); + const weight = (await readFreshness()).WEIGHT; + expect(weight.lastTrigger).toBe("foreground"); + expect(weight.lastReceivedAt).not.toBe( + background.lastReceivedAt.toISOString(), + ); + expect(weight.lastBackgroundAt).toBe( + background.lastBackgroundAt?.toISOString(), + ); + }); + + it("does not date a background arrival for a push-triggered batch", async () => { + await armSession(); + await postBatch({ + syncTrigger: "push", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 70, "kg", T1, "uuid-w1"), + ], + }); + expect((await readFreshness()).WEIGHT.lastBackgroundAt).toBeNull(); + }); +});