test(healthkit): pin dose arrivals, the background date and migration 0401 over enum rows

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:36:20 +02:00
1 parent 03b24461ad
commit 09072c9f62
3 files changed
+414

No files matched your search

@@ -426,6 +426,52 @@ describe("<AppleHealthCard> — 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,
@@ -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<void> {
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<Row[]>`
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<Array<{ indexdef: string }>>`
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();
});
});
@@ -300,3 +300,182 @@ describe("per-type arrival ledger (#1173)", () => {
});
});
});
async function seedAppleMedication(): Promise<string> {
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<string, unknown>): Promise<Response> {
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<Record<string, FreshnessRow>> {
const { GET } = await import("@/app/api/integrations/healthkit/route");
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
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();
});
});