mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
/**
|
|
* Hour buckets for summed types and the `backfill` trigger (ios #77), against
|
|
* real Postgres.
|
|
*
|
|
* An hourly `stats:<HK>:<YYYY-MM-DD>T<HH>` row must never become a
|
|
* measurement: the day total stays the day row, and an hour stored as a
|
|
* measurement would be counted into that day a second time. The hours land in
|
|
* the intraday profile instead, which is what makes the same-time baseline
|
|
* usable on day one after a thirty-day backfill. And a backfill is a
|
|
* catch-up, so it must not move the live sync stamps.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
process.env.API_TOKEN_HMAC_KEY ??=
|
|
"test-hmac-key-hour-buckets-backfill-32-bytes-minimum-1234";
|
|
|
|
const USER_ID = "user-hour-buckets";
|
|
const STEPS = "HKQuantityTypeIdentifierStepCount";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => cookieJar.set(name, value),
|
|
delete: (name: string) => cookieJar.delete(name),
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: USER_ID,
|
|
username: "hour-buckets",
|
|
email: "hour-buckets@example.test",
|
|
timezone: "UTC",
|
|
},
|
|
});
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
});
|
|
|
|
async function postBatch(body: Record<string, unknown>) {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
const res = await POST(
|
|
new NextRequest("http://localhost/api/measurements/batch", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
return (await res.json()).data as {
|
|
inserted: number;
|
|
updated: number;
|
|
entries: Array<{ index: number; status: string; reason?: string }>;
|
|
};
|
|
}
|
|
|
|
function dayKeyBack(days: number): string {
|
|
return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function hourEntry(dateKey: string, hour: number, value: number) {
|
|
const hh = String(hour).padStart(2, "0");
|
|
const start = `${dateKey}T${hh}:00:00.000Z`;
|
|
return {
|
|
hkIdentifier: STEPS,
|
|
value,
|
|
unit: "count",
|
|
startDate: start,
|
|
endDate: new Date(Date.parse(start) + 3_600_000).toISOString(),
|
|
externalId: `stats:${STEPS}:${dateKey}T${hh}`,
|
|
};
|
|
}
|
|
|
|
function dayEntry(dateKey: string, value: number) {
|
|
return {
|
|
hkIdentifier: STEPS,
|
|
value,
|
|
unit: "count",
|
|
startDate: `${dateKey}T00:00:00.000Z`,
|
|
endDate: `${dateKey}T12:00:00.000Z`,
|
|
externalId: `stats:${STEPS}:${dateKey}`,
|
|
};
|
|
}
|
|
|
|
describe("hour buckets for summed types", () => {
|
|
it("keeps the day total as the only measurement and builds the profile from the hours", async () => {
|
|
const day = dayKeyBack(2);
|
|
const hours = Array.from({ length: 24 }, (_, h) =>
|
|
hourEntry(day, h, h * 10),
|
|
);
|
|
const total = hours.reduce((s, e) => s + e.value, 0);
|
|
|
|
const first = await postBatch({
|
|
entries: [dayEntry(day, total), ...hours],
|
|
});
|
|
expect(first.entries.slice(1).every((e) => e.status === "inserted")).toBe(
|
|
true,
|
|
);
|
|
|
|
const rows = await getPrismaClient().measurement.findMany({
|
|
where: { userId: USER_ID, type: "ACTIVITY_STEPS" },
|
|
select: { value: true, externalId: true },
|
|
});
|
|
// The guard: no hour of a summed type ever reaches `measurements`.
|
|
expect(rows).toEqual([
|
|
{ value: total, externalId: `stats:${STEPS}:${day}` },
|
|
]);
|
|
|
|
const profile =
|
|
await getPrismaClient().intradayCumulativeProfile.findUniqueOrThrow({
|
|
where: {
|
|
userId_type_dateKey: {
|
|
userId: USER_ID,
|
|
type: "ACTIVITY_STEPS",
|
|
dateKey: day,
|
|
},
|
|
},
|
|
});
|
|
expect(profile.basis).toBe("HOUR_BUCKETS");
|
|
expect(profile.bucketCount).toBe(24);
|
|
expect(profile.hourlyIncrements).toEqual(hours.map((e) => e.value));
|
|
expect(profile.hourlyCumulative[23]).toBe(total);
|
|
expect(profile.dayTotal).toBe(total);
|
|
expect(profile.timezone).toBe("UTC");
|
|
|
|
// A re-post of one hour overwrites it.
|
|
const again = await postBatch({ entries: [hourEntry(day, 5, 999)] });
|
|
expect(again.entries[0]).toMatchObject({ status: "updated" });
|
|
expect(again.updated).toBe(1);
|
|
const after =
|
|
await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({
|
|
where: { userId: USER_ID, dateKey: day },
|
|
});
|
|
expect(after.hourlyIncrements[5]).toBe(999);
|
|
expect(after.dayTotal).toBe(total - 50 + 999);
|
|
// And the day row did not move.
|
|
const dayRow = await getPrismaClient().measurement.findFirstOrThrow({
|
|
where: { userId: USER_ID, type: "ACTIVITY_STEPS" },
|
|
});
|
|
expect(dayRow.value).toBe(total);
|
|
});
|
|
|
|
it("keeps a day under four hours on its per-sample shape", async () => {
|
|
const day = dayKeyBack(3);
|
|
await postBatch({
|
|
entries: [hourEntry(day, 8, 100), hourEntry(day, 9, 200)],
|
|
});
|
|
const profile =
|
|
await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({
|
|
where: { userId: USER_ID, dateKey: day },
|
|
});
|
|
expect(profile.basis).toBe("PER_SAMPLE");
|
|
expect(profile.bucketCount).toBe(2);
|
|
expect(profile.hourlyCumulative).toEqual([]);
|
|
|
|
// The drain's fold then fills the curve; the posted hours stay put.
|
|
const { mergeIntradayCumulativeProfile } =
|
|
await import("@/lib/measurements/intraday-cumulative-profile");
|
|
await getPrismaClient().$transaction((tx) =>
|
|
mergeIntradayCumulativeProfile(tx, {
|
|
userId: USER_ID,
|
|
type: "ACTIVITY_STEPS",
|
|
increment: {
|
|
dateKey: day,
|
|
timezone: "UTC",
|
|
hourlyCumulative: Array.from({ length: 24 }, (_, h) =>
|
|
h >= 8 ? 300 : 0,
|
|
),
|
|
dayTotal: 300,
|
|
sampleCount: 30,
|
|
},
|
|
}),
|
|
);
|
|
const folded =
|
|
await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({
|
|
where: { userId: USER_ID, dateKey: day },
|
|
});
|
|
expect(folded.dayTotal).toBe(300);
|
|
expect(folded.hourlyIncrements[9]).toBe(200);
|
|
});
|
|
|
|
it("does not let the per-sample fold count a bucket-built day twice", async () => {
|
|
const day = dayKeyBack(4);
|
|
await postBatch({
|
|
entries: Array.from({ length: 6 }, (_, h) => hourEntry(day, h + 8, 100)),
|
|
});
|
|
const { mergeIntradayCumulativeProfile } =
|
|
await import("@/lib/measurements/intraday-cumulative-profile");
|
|
await getPrismaClient().$transaction((tx) =>
|
|
mergeIntradayCumulativeProfile(tx, {
|
|
userId: USER_ID,
|
|
type: "ACTIVITY_STEPS",
|
|
increment: {
|
|
dateKey: day,
|
|
timezone: "UTC",
|
|
hourlyCumulative: new Array(24).fill(600),
|
|
dayTotal: 600,
|
|
sampleCount: 60,
|
|
},
|
|
}),
|
|
);
|
|
const profile =
|
|
await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({
|
|
where: { userId: USER_ID, dateKey: day },
|
|
});
|
|
expect(profile.basis).toBe("HOUR_BUCKETS");
|
|
expect(profile.dayTotal).toBe(600);
|
|
expect(profile.sampleCount).toBe(6);
|
|
});
|
|
|
|
it("names every refusal", async () => {
|
|
const day = dayKeyBack(2);
|
|
const misaligned = {
|
|
...hourEntry(day, 9, 10),
|
|
startDate: `${day}T09:30:00.000Z`,
|
|
};
|
|
const unsupported = {
|
|
...hourEntry(day, 9, 10),
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
unit: "kg",
|
|
externalId: `stats:HKQuantityTypeIdentifierBodyMass:${day}T09`,
|
|
};
|
|
const old = hourEntry("2024-01-02", 9, 10);
|
|
const result = await postBatch({ entries: [misaligned, unsupported, old] });
|
|
expect(result.entries.map((e) => e.reason)).toEqual([
|
|
"hour_bucket_misaligned",
|
|
"hour_bucket_unsupported_type",
|
|
"hour_bucket_too_old",
|
|
]);
|
|
expect(
|
|
await getPrismaClient().intradayCumulativeProfile.count({
|
|
where: { userId: USER_ID },
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("makes the same-time baseline usable on day one after a thirty-day backfill", async () => {
|
|
const entries = [];
|
|
for (let back = 31; back >= 1; back -= 1) {
|
|
const day = dayKeyBack(back);
|
|
for (let h = 0; h < 24; h += 1) entries.push(hourEntry(day, h, 50 + h));
|
|
}
|
|
for (let i = 0; i < entries.length; i += 400) {
|
|
await postBatch({
|
|
syncTrigger: "backfill",
|
|
entries: entries.slice(i, i + 400),
|
|
});
|
|
}
|
|
const { computeSameTimeBaseline } =
|
|
await import("@/lib/insights/derived/same-time-baseline");
|
|
// "Today" is the last backfilled day, at 20:30.
|
|
const now = new Date(`${dayKeyBack(1)}T20:30:00.000Z`);
|
|
const derived = await computeSameTimeBaseline(USER_ID, null, {
|
|
type: "ACTIVITY_STEPS",
|
|
now,
|
|
});
|
|
expect(derived.status).toBe("ok");
|
|
});
|
|
});
|
|
|
|
describe('syncTrigger "backfill"', () => {
|
|
it("records lastBackfillAt and moves none of the live stamps", async () => {
|
|
const live = await postBatch({
|
|
syncTrigger: "foreground",
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
|
value: 61,
|
|
unit: "count/min",
|
|
startDate: "2026-10-01T06:00:00.000Z",
|
|
endDate: "2026-10-01T06:00:00.000Z",
|
|
externalId: "uuid-live-1",
|
|
},
|
|
],
|
|
});
|
|
expect(live.inserted).toBe(1);
|
|
const prisma = getPrismaClient();
|
|
const userBefore = await prisma.user.findUniqueOrThrow({
|
|
where: { id: USER_ID },
|
|
select: {
|
|
lastSyncedAt: true,
|
|
healthKitLastSyncedAt: true,
|
|
healthKitLastSyncTrigger: true,
|
|
healthKitLastBackgroundSyncAt: true,
|
|
},
|
|
});
|
|
const ledgerBefore = await prisma.healthKitTypeSync.findUniqueOrThrow({
|
|
where: { userId_type: { userId: USER_ID, type: "PULSE" } },
|
|
});
|
|
|
|
const backfill = await postBatch({
|
|
syncTrigger: "backfill",
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
|
value: 63,
|
|
unit: "count/min",
|
|
startDate: "2026-09-01T06:00:00.000Z",
|
|
endDate: "2026-09-01T06:00:00.000Z",
|
|
externalId: "uuid-backfill-1",
|
|
},
|
|
],
|
|
});
|
|
expect(backfill.inserted).toBe(1);
|
|
|
|
expect(
|
|
await prisma.user.findUniqueOrThrow({
|
|
where: { id: USER_ID },
|
|
select: {
|
|
lastSyncedAt: true,
|
|
healthKitLastSyncedAt: true,
|
|
healthKitLastSyncTrigger: true,
|
|
healthKitLastBackgroundSyncAt: true,
|
|
},
|
|
}),
|
|
).toEqual(userBefore);
|
|
expect(
|
|
await prisma.healthKitTypeSync.findUniqueOrThrow({
|
|
where: { userId_type: { userId: USER_ID, type: "PULSE" } },
|
|
}),
|
|
).toEqual(ledgerBefore);
|
|
const backfillRow = await prisma.healthKitTypeSync.findUniqueOrThrow({
|
|
where: { userId_type: { userId: USER_ID, type: "backfill:PULSE" } },
|
|
});
|
|
expect(backfillRow.lastTrigger).toBeNull();
|
|
|
|
// The status read serves it, and the live facts are the foreground ones.
|
|
const { GET } = await import("@/app/api/integrations/healthkit/route");
|
|
const res = await (GET as () => Promise<Response>)();
|
|
const body = (await res.json()).data as {
|
|
metricFreshness: Array<Record<string, unknown>>;
|
|
syncProgress: { recordsAccepted: number } | null;
|
|
};
|
|
const pulse = body.metricFreshness.find((e) => e.type === "PULSE");
|
|
expect(pulse).toMatchObject({
|
|
lastReceivedAt: ledgerBefore.lastReceivedAt.toISOString(),
|
|
lastTrigger: "foreground",
|
|
lastBackfillAt: backfillRow.lastReceivedAt.toISOString(),
|
|
});
|
|
// syncProgress counts what arrived, the backfill included.
|
|
expect(body.syncProgress?.recordsAccepted).toBe(2);
|
|
expect(
|
|
body.metricFreshness.some((e) => String(e.type).startsWith("backfill:")),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("is refused by name for anything outside the enum", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
const res = await POST(
|
|
new NextRequest("http://localhost/api/measurements/batch", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
syncTrigger: "history",
|
|
entries: [dayEntry("2026-10-01", 1)],
|
|
}),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
});
|