mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Snapshot the incremental watermark once per sync cycle and thread the same lower bound to every resource (metrics, activity, sleep, workout), stamping lastSyncedAt once at the end. Previously each resource read lastSyncedAt then stamped now() independently, so the first resource advanced the watermark and later resources only fetched the last overlap window — silently dropping the gap after an outage longer than the overlap. The full/backfill path is unaffected (it passes no lower bound). Reject an OAuth callback whose token response carries no refresh_token instead of persisting an empty string. An empty refresh token bricks every future token refresh; the callback now errors out cleanly so the user re-consents rather than landing a dead connection. Model the INTERVAL data types (steps/distance/calories/floors daily totals, sleep and exercise sessions) on Google Health's interval.start_time / interval.civil_start_time anchor instead of a sample_time / bare date. The incremental filter for sleep/exercise previously targeted sample_time.physical_time, which 400s/empties for interval types and stalls incremental sync. Daily-summary metrics and spot readings keep their existing date / sample anchors. Shape mismatches still fail safe (return []/null). Mark the Google Health (Fitbit & Pixel) settings card as experimental with a badge and a short note that data coverage is still being verified, in all six locales. (cherry picked from commit 3f116802149eebe9144729e2779f7f4405f248f2)
281 lines
9.6 KiB
TypeScript
281 lines
9.6 KiB
TypeScript
/**
|
|
* v1.12.0 (W5) — Fitbit activity / sleep / workout sync end-to-end.
|
|
*
|
|
* Drives the three W5 resource syncs against a mocked Google Health
|
|
* `dataPoints.list` and the real Postgres testcontainer. Asserts:
|
|
* - Daily cumulative activity rows mint a `stats:`-prefixed externalId and a
|
|
* re-fetched day OVERWRITES the row in place (no duplicate) — the
|
|
* Apple-Health daily-total overwrite contract.
|
|
* - Sleep sessions upsert per-stage SLEEP_DURATION rows carrying the shared
|
|
* SleepStage enum, distinct per stage under the dedup key.
|
|
* - A Fitbit exercise session writes a Workout row keyed
|
|
* (userId, source=FITBIT, externalId); a Fitbit run and an Apple-Health twin
|
|
* both persist, and the read-time canonical picker collapses them to the
|
|
* ladder winner (APPLE_HEALTH > WHOOP > FITBIT).
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Pin a deterministic 32-byte key before any `@/lib/crypto` import.
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { encrypt } from "@/lib/crypto";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({
|
|
getGlobalBoss: vi.fn(() => null),
|
|
}));
|
|
|
|
const TEST_USER_ID = "user-fitbit-w5-sync";
|
|
|
|
beforeEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: {
|
|
id: TEST_USER_ID,
|
|
username: "fitbit-w5-sync",
|
|
email: "fitbit-w5-sync@example.test",
|
|
},
|
|
});
|
|
await prisma.fitbitConnection.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
fitbitUserId: "gh-w5",
|
|
accessToken: encrypt("access-token"),
|
|
refreshToken: encrypt("refresh-token"),
|
|
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
scope:
|
|
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly https://www.googleapis.com/auth/googlehealth.sleep.readonly",
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
/** Stub fetch: match the kebab path segment → its seeded data points. */
|
|
function stubGoogleHealth(byPathSegment: Record<string, unknown[]>) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (url: string) => {
|
|
const match = /dataTypes\/([^/?]+)\/dataPoints/.exec(url);
|
|
const seg = match?.[1] ?? "";
|
|
const dataPoints = byPathSegment[seg] ?? [];
|
|
return { status: 200, json: async () => ({ dataPoints }) };
|
|
}),
|
|
);
|
|
}
|
|
|
|
// INTERVAL data type: the daily total is bucketed into an `interval` carrying a
|
|
// physical `start_time` (the day's start instant), NOT a bare civil `date`.
|
|
const DAY_INTERVAL = { start_time: "2026-05-10T00:00:00.000Z" };
|
|
|
|
describe("syncUserActivity — cumulative overwrite", () => {
|
|
it("mints a stats: externalId and overwrites the same day on re-sync (no duplicate)", async () => {
|
|
stubGoogleHealth({
|
|
steps: [{ steps: { count: 8000, interval: DAY_INTERVAL } }],
|
|
});
|
|
const { syncUserActivity } = await import("@/lib/fitbit/sync-activity");
|
|
const first = await syncUserActivity(TEST_USER_ID);
|
|
expect(first).toBe(1);
|
|
|
|
const prisma = getPrismaClient();
|
|
let rows = await prisma.measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", source: "FITBIT" },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.value).toBe(8000);
|
|
expect(rows[0]!.unit).toBe("steps");
|
|
// The daily-total overwrite shape: stats:<tag>:<YYYY-MM-DD>.
|
|
expect(rows[0]!.externalId).toBe("stats:steps:2026-05-10");
|
|
|
|
// Re-fetch the same day with a corrected total → overwrite, not duplicate.
|
|
stubGoogleHealth({
|
|
steps: [{ steps: { count: 9123, interval: DAY_INTERVAL } }],
|
|
});
|
|
await syncUserActivity(TEST_USER_ID);
|
|
|
|
rows = await prisma.measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", source: "FITBIT" },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.value).toBe(9123);
|
|
expect(rows[0]!.syncVersion).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("preserves a 0-step rest day rather than dropping it as a gap", async () => {
|
|
stubGoogleHealth({
|
|
steps: [{ steps: { count: 0, interval: DAY_INTERVAL } }],
|
|
});
|
|
const { syncUserActivity } = await import("@/lib/fitbit/sync-activity");
|
|
const imported = await syncUserActivity(TEST_USER_ID);
|
|
expect(imported).toBe(1);
|
|
|
|
const prisma = getPrismaClient();
|
|
const rows = await prisma.measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", source: "FITBIT" },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.value).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("syncUserSleep — per-stage upsert", () => {
|
|
it("writes one SLEEP_DURATION row per stage carrying the SleepStage enum", async () => {
|
|
const session = {
|
|
sleep: {
|
|
startTime: "2026-05-10T22:00:00.000Z",
|
|
endTime: "2026-05-11T06:00:00.000Z",
|
|
segments: [
|
|
{
|
|
stage: "light",
|
|
startTime: "2026-05-10T22:00:00.000Z",
|
|
endTime: "2026-05-10T23:00:00.000Z",
|
|
},
|
|
{
|
|
stage: "deep",
|
|
startTime: "2026-05-10T23:00:00.000Z",
|
|
endTime: "2026-05-11T00:30:00.000Z",
|
|
},
|
|
{
|
|
stage: "rem",
|
|
startTime: "2026-05-11T00:30:00.000Z",
|
|
endTime: "2026-05-11T01:15:00.000Z",
|
|
},
|
|
],
|
|
},
|
|
};
|
|
stubGoogleHealth({ sleep: [session] });
|
|
const { syncUserSleep } = await import("@/lib/fitbit/sync-sleep");
|
|
const imported = await syncUserSleep(TEST_USER_ID);
|
|
expect(imported).toBe(3);
|
|
|
|
const prisma = getPrismaClient();
|
|
const rows = await prisma.measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "SLEEP_DURATION", source: "FITBIT" },
|
|
orderBy: { sleepStage: "asc" },
|
|
});
|
|
expect(rows).toHaveLength(3);
|
|
const byStage = Object.fromEntries(rows.map((r) => [r.sleepStage, r]));
|
|
expect(byStage.CORE!.value).toBe(60); // light → CORE, 60 min
|
|
expect(byStage.DEEP!.value).toBe(90);
|
|
expect(byStage.REM!.value).toBe(45);
|
|
// Each stage row is distinct under the dedup key.
|
|
expect(new Set(rows.map((r) => r.externalId)).size).toBe(3);
|
|
|
|
// Re-score the same night → overwrite in place (still 3 rows).
|
|
await syncUserSleep(TEST_USER_ID);
|
|
const again = await prisma.measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "SLEEP_DURATION", source: "FITBIT" },
|
|
});
|
|
expect(again).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe("syncUserWorkout — Workout rows + cross-source dedup", () => {
|
|
it("writes a Workout row keyed (userId, FITBIT, externalId)", async () => {
|
|
stubGoogleHealth({
|
|
exercise: [
|
|
{
|
|
exercise: {
|
|
session_id: "ex-1",
|
|
activity_type: "run",
|
|
startTime: "2026-05-10T07:00:00.000Z",
|
|
endTime: "2026-05-10T07:40:00.000Z",
|
|
active_kilocalories: 380,
|
|
distance: { meters: 7000 },
|
|
average_heart_rate: { beats_per_minute: 150 },
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const { syncUserWorkout } = await import("@/lib/fitbit/sync-workout");
|
|
const imported = await syncUserWorkout(TEST_USER_ID);
|
|
expect(imported).toBe(1);
|
|
|
|
const prisma = getPrismaClient();
|
|
const workouts = await prisma.workout.findMany({
|
|
where: { userId: TEST_USER_ID, source: "FITBIT" },
|
|
});
|
|
expect(workouts).toHaveLength(1);
|
|
expect(workouts[0]).toMatchObject({
|
|
externalId: "ex-1",
|
|
sportType: "running",
|
|
durationSec: 40 * 60,
|
|
totalEnergyKcal: 380,
|
|
totalDistanceM: 7000,
|
|
avgHeartRate: 150,
|
|
});
|
|
|
|
// Re-fetch → overwrite in place, still one row.
|
|
await syncUserWorkout(TEST_USER_ID);
|
|
expect(
|
|
await prisma.workout.count({
|
|
where: { userId: TEST_USER_ID, source: "FITBIT" },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("keeps a Fitbit + Apple-Health twin as two rows; the read-time picker collapses them", async () => {
|
|
const prisma = getPrismaClient();
|
|
// A pre-existing Apple-Health run at the same wall-clock time.
|
|
await prisma.workout.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
source: "APPLE_HEALTH",
|
|
externalId: "hk-run-1",
|
|
sportType: "running",
|
|
startedAt: new Date("2026-05-10T07:00:30.000Z"),
|
|
endedAt: new Date("2026-05-10T07:40:00.000Z"),
|
|
durationSec: 2370,
|
|
},
|
|
});
|
|
|
|
stubGoogleHealth({
|
|
exercise: [
|
|
{
|
|
exercise: {
|
|
session_id: "ex-1",
|
|
activity_type: "run",
|
|
startTime: "2026-05-10T07:00:00.000Z",
|
|
endTime: "2026-05-10T07:40:00.000Z",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const { syncUserWorkout } = await import("@/lib/fitbit/sync-workout");
|
|
await syncUserWorkout(TEST_USER_ID);
|
|
|
|
// Both rows persist at ingest (no server-owned-pair collapse on write).
|
|
const allRows = await prisma.workout.findMany({
|
|
where: { userId: TEST_USER_ID },
|
|
orderBy: { source: "asc" },
|
|
});
|
|
expect(allRows).toHaveLength(2);
|
|
expect(allRows.map((r) => r.source).sort()).toEqual([
|
|
"APPLE_HEALTH",
|
|
"FITBIT",
|
|
]);
|
|
|
|
// Read-time canonical picker collapses the twin to the ladder winner.
|
|
const { pickCanonicalWorkoutRows } = await import(
|
|
"@/lib/measurements/pick-canonical-workout-rows"
|
|
);
|
|
const canonical = pickCanonicalWorkoutRows(
|
|
allRows.map((r) => ({
|
|
startedAt: r.startedAt,
|
|
sportType: r.sportType,
|
|
source: r.source,
|
|
})),
|
|
null,
|
|
);
|
|
expect(canonical).toHaveLength(1);
|
|
// APPLE_HEALTH ranks above FITBIT in the default ladder.
|
|
expect(canonical[0]!.source).toBe("APPLE_HEALTH");
|
|
});
|
|
});
|