mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(fitbit): sync activity, sleep stages, and workouts from Google Health
Extend the Fitbit / Google Health provider with the remaining sync resources, driven by the existing syncUserFitbit orchestrator. Activity (daily cumulative): steps -> ACTIVITY_STEPS, distance -> WALKING_RUNNING_DISTANCE, the active-energy portion -> ACTIVE_ENERGY_BURNED, floors -> FLIGHTS_CLIMBED, and VO2 max -> VO2_MAX. Each daily total mints a stats:<tag>:<YYYY-MM-DD> externalId so a re-fetched day overwrites in place rather than duplicating, matching the Apple-Health daily-total contract. The running totals preserve a legitimate zero (a rest day is real data); VO2 max stays strictly positive (daily latest-wins). Sleep: map a session's per-stage segments to per-stage SLEEP_DURATION rows (minutes; measuredAt = the stage end), harmonised onto the shared SleepStage enum (light -> CORE, deep -> DEEP, rem -> REM, awake/restless -> AWAKE, in-bed -> IN_BED, classic asleep -> ASLEEP) so the night-total and hypnogram readers consume the same shape they already read for WHOOP and Apple. Workouts: map exercise sessions to Workout rows keyed (userId, FITBIT, externalId), with sport-type, start/end, duration, energy, distance, and HR. Cross-source twins stay distinct at ingest; the read-time canonical picker collapses them via the source ladder (Fitbit already ranked below WHOOP). All three join the orchestrator's resource list and ride the per-resource 403 soft-skip so an independently-granted Restricted bundle that the user withheld skips only its own resource. Rollups recompute and status-insight caches invalidate through the shared upsert. No new MeasurementType, no migration, no new dependency.
This commit is contained in:
1 parent
f44dccb828
commit
6ad0a8a5cf
9 files changed
+1485
-15
No files matched your search
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 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 }) };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const DAY = { year: 2026, month: 5, day: 10 };
|
||||
|
||||
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, date: DAY } }] });
|
||||
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, date: DAY } }] });
|
||||
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, date: DAY } }] });
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user