Files
HealthLog/tests/integration/fitbit-activity-sleep-workout-sync.test.ts
T
Marc-André Bombeck 6ad0a8a5cf 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.
2026-06-04 22:57:44 +02:00

273 lines
9.3 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 }) };
}),
);
}
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");
});
});