mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(healthkit): pin dose arrivals, the background date and migration 0401 over enum rows
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user