diff --git a/tests/integration/fitbit-activity-sleep-workout-sync.test.ts b/tests/integration/fitbit-activity-sleep-workout-sync.test.ts index e2f0fbe2d..1fc826223 100644 --- a/tests/integration/fitbit-activity-sleep-workout-sync.test.ts +++ b/tests/integration/fitbit-activity-sleep-workout-sync.test.ts @@ -1,8 +1,8 @@ /** - * v1.12.0 (W5) — Fitbit activity / sleep / workout sync end-to-end. + * v1.20.0 — Fitbit activity / sleep / workout sync end-to-end (classic API). * - * Drives the three W5 resource syncs against a mocked Google Health - * `dataPoints.list` and the real Postgres testcontainer. Asserts: + * Drives the three resource syncs against a mocked classic `api.fitbit.com` + * transport 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. @@ -37,17 +37,22 @@ beforeEach(async () => { id: TEST_USER_ID, username: "fitbit-w5-sync", email: "fitbit-w5-sync@example.test", + // Pin UTC so the classic offset-less local wall-clock timestamps Fitbit + // emits on the 1.2 sleep log + activities list resolve 1:1 to UTC instants + // — the sleep/workout assertions below anchor on those instants, and the + // workout twin must land in the same 5-minute picker bucket as its + // Apple-Health counterpart regardless of the server default zone. + timezone: "UTC", }, }); await prisma.fitbitConnection.create({ data: { userId: TEST_USER_ID, - fitbitUserId: "gh-w5", + fitbitUserId: "fb-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", + scope: "activity sleep", }, }); }); @@ -57,27 +62,35 @@ afterEach(() => { vi.restoreAllMocks(); }); -/** Stub fetch: match the kebab path segment → its seeded data points. */ -function stubGoogleHealth(byPathSegment: Record) { +/** + * Stub global fetch for the classic Fitbit Web API. Each resource sync hits one + * bespoke endpoint per metric; match on a discriminating substring of the URL + * path and return its seeded classic-shaped body. Endpoints not seeded return + * an empty envelope so their mappers yield no rows (the activity sync walks + * steps + distance + activityCalories + floors + cardioscore, so the unseeded + * siblings must produce zero rows for the per-metric counts to hold). + */ +function stubFitbit(byPath: Record) { 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 }) }; + for (const [needle, body] of Object.entries(byPath)) { + if (url.includes(needle)) { + return { status: 200, json: async () => body }; + } + } + return { status: 200, json: async () => ({}) }; }), ); } -// 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 } }], + stubFitbit({ + // Classic activities time series: civil-date rows, string values. + "activities/steps": { + "activities-steps": [{ dateTime: "2026-05-10", value: "8000" }], + }, }); const { syncUserActivity } = await import("@/lib/fitbit/sync-activity"); const first = await syncUserActivity(TEST_USER_ID); @@ -94,8 +107,10 @@ describe("syncUserActivity — cumulative overwrite", () => { 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 } }], + stubFitbit({ + "activities/steps": { + "activities-steps": [{ dateTime: "2026-05-10", value: "9123" }], + }, }); await syncUserActivity(TEST_USER_ID); @@ -108,8 +123,10 @@ describe("syncUserActivity — cumulative overwrite", () => { }); it("preserves a 0-step rest day rather than dropping it as a gap", async () => { - stubGoogleHealth({ - steps: [{ steps: { count: 0, interval: DAY_INTERVAL } }], + stubFitbit({ + "activities/steps": { + "activities-steps": [{ dateTime: "2026-05-10", value: "0" }], + }, }); const { syncUserActivity } = await import("@/lib/fitbit/sync-activity"); const imported = await syncUserActivity(TEST_USER_ID); @@ -126,30 +143,26 @@ describe("syncUserActivity — cumulative overwrite", () => { describe("syncUserSleep — per-stage upsert", () => { it("writes one SLEEP_DURATION row per stage carrying the SleepStage enum", async () => { + // Classic 1.2 sleep log: one session, per-segment `levels.data`. The segment + // `dateTime` is its START (offset-less local wall-clock); seconds → minutes; + // measuredAt = START + seconds. logId anchors the externalId. const session = { - sleep: { - startTime: "2026-05-10T22:00:00.000Z", - endTime: "2026-05-11T06:00:00.000Z", - segments: [ + logId: 555001, + startTime: "2026-05-10T22:00:00.000", + endTime: "2026-05-11T06:00:00.000", + levels: { + data: [ { - 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", + dateTime: "2026-05-10T22:00:00.000", + level: "light", + seconds: 3600, }, + { dateTime: "2026-05-10T23:00:00.000", level: "deep", seconds: 5400 }, + { dateTime: "2026-05-11T00:30:00.000", level: "rem", seconds: 2700 }, ], }, }; - stubGoogleHealth({ sleep: [session] }); + stubFitbit({ "sleep/date": { sleep: [session] } }); const { syncUserSleep } = await import("@/lib/fitbit/sync-sleep"); const imported = await syncUserSleep(TEST_USER_ID); expect(imported).toBe(3); @@ -178,20 +191,21 @@ describe("syncUserSleep — per-stage upsert", () => { 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 }, + stubFitbit({ + // Classic activities list: logId anchor, ms duration, km distance. + "activities/list": { + activities: [ + { + logId: 90001, + activityName: "run", + startTime: "2026-05-10T07:00:00.000", + duration: 40 * 60 * 1000, + calories: 380, + distance: 7, // km (metric locale) → 7000 m + averageHeartRate: 150, }, - }, - ], + ], + }, }); const { syncUserWorkout } = await import("@/lib/fitbit/sync-workout"); const imported = await syncUserWorkout(TEST_USER_ID); @@ -203,7 +217,8 @@ describe("syncUserWorkout — Workout rows + cross-source dedup", () => { }); expect(workouts).toHaveLength(1); expect(workouts[0]).toMatchObject({ - externalId: "ex-1", + // Classic externalId = String(logId). + externalId: "90001", sportType: "running", durationSec: 40 * 60, totalEnergyKcal: 380, @@ -235,17 +250,17 @@ describe("syncUserWorkout — Workout rows + cross-source dedup", () => { }, }); - stubGoogleHealth({ - exercise: [ - { - exercise: { - session_id: "ex-1", - activity_type: "run", - startTime: "2026-05-10T07:00:00.000Z", - endTime: "2026-05-10T07:40:00.000Z", + stubFitbit({ + "activities/list": { + activities: [ + { + logId: 90001, + activityName: "run", + startTime: "2026-05-10T07:00:00.000", + duration: 40 * 60 * 1000, }, - }, - ], + ], + }, }); const { syncUserWorkout } = await import("@/lib/fitbit/sync-workout"); await syncUserWorkout(TEST_USER_ID); diff --git a/tests/integration/fitbit-metrics-sync.test.ts b/tests/integration/fitbit-metrics-sync.test.ts index 174b6271b..fb21a371e 100644 --- a/tests/integration/fitbit-metrics-sync.test.ts +++ b/tests/integration/fitbit-metrics-sync.test.ts @@ -1,9 +1,9 @@ /** - * v1.12.0 — Fitbit / Google Health metrics-sync end-to-end. + * v1.20.0 — Fitbit health-metrics sync end-to-end (classic Fitbit Web API). * - * Drives `syncUserMetrics` against a mocked Google Health `dataPoints.list` - * response and the real Postgres testcontainer. Asserts: - * - Each mapped data point writes one Measurement row keyed + * Drives `syncUserMetrics` against a mocked classic `api.fitbit.com` transport + * and the real Postgres testcontainer. Asserts: + * - Each mapped reading writes one Measurement row keyed * `(userId, type, source=FITBIT, externalId)`. * - A second sync of the same window UPSERTS in place (no duplicates) and * bumps `syncVersion` — the idempotency contract. @@ -44,13 +44,13 @@ beforeEach(async () => { await prisma.fitbitConnection.create({ data: { userId: TEST_USER_ID, - fitbitUserId: "gh-1", + fitbitUserId: "fb-1", accessToken: encrypt("access-token"), refreshToken: encrypt("refresh-token"), // Token valid for an hour — keeps `getValidToken` off the refresh path. tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), scope: - "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", + "activity heartrate oxygen_saturation profile respiratory_rate weight", }, }); }); @@ -61,45 +61,46 @@ afterEach(() => { }); /** - * Stub global fetch so each metric data-type read returns its own data points. - * The metrics sync hits one URL per data type (kebab-cased in the path); match - * on the path segment and return the matching point set, or an empty page for - * any type we don't seed. + * Stub global fetch for the classic Fitbit Web API. The metrics sync hits one + * bespoke endpoint per metric (each a `/1/user/-//date/{start}/{end}` + * range read), so match on the resource path segment and return its seeded + * classic-shaped body. Any endpoint not seeded returns its empty envelope. + * + * Each metric resource is keyed by a discriminating substring of its URL path: + * - `body/log/weight` → `{ weight: [...] }` + * - `body/log/fat` → `{ fat: [...] }` + * - `spo2` → bare `[ { dateTime, value: { avg } } ]` + * - `hrv` → `{ hrv: [...] }` + * - `activities/heart`→ `{ "activities-heart": [...] }` + * - `br` → `{ br: [...] }` */ -function stubGoogleHealth(byPathSegment: Record) { +function stubFitbit(byPath: Record) { 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 }), - }; + for (const [needle, body] of Object.entries(byPath)) { + if (url.includes(needle)) { + return { status: 200, json: async () => body }; + } + } + // Unseeded endpoint: empty body so its mapper yields no rows. + return { status: 200, json: async () => ({}) }; }), ); } describe("syncUserMetrics — integration", () => { - it("writes one Measurement row per mapped data point and folds a DAY rollup", async () => { - stubGoogleHealth({ - weight: [ - { - weight: { - kilograms: 80.5, - sample_time: { physical_time: "2026-05-10T07:00:00.000Z" }, - }, - }, - ], - "daily-resting-heart-rate": [ - { - daily_resting_heart_rate: { - beats_per_minute: 55, - date: { year: 2026, month: 5, day: 10 }, - }, - }, - ], + it("writes one Measurement row per mapped reading and folds a DAY rollup", async () => { + stubFitbit({ + "body/log/weight": { + // No logId → externalId anchors on the instant ISO (date + time). + weight: [{ date: "2026-05-10", time: "07:00:00", weight: 80.5 }], + }, + "activities/heart": { + "activities-heart": [ + { dateTime: "2026-05-10", value: { restingHeartRate: 55 } }, + ], + }, }); const { syncUserMetrics } = await import("@/lib/fitbit/sync-metrics"); @@ -130,19 +131,17 @@ describe("syncUserMetrics — integration", () => { }); it("upserts in place on a re-sync of the same window (no duplicates) and bumps syncVersion", async () => { - const point = (kg: number) => ({ - weight: { - kilograms: kg, - sample_time: { physical_time: "2026-05-10T07:00:00.000Z" }, - }, + const body = (kg: number) => ({ + // No logId → stable instant anchor → same dedup key on re-fetch. + weight: [{ date: "2026-05-10", time: "07:00:00", weight: kg }], }); - stubGoogleHealth({ weight: [point(80.5)] }); + stubFitbit({ "body/log/weight": body(80.5) }); const { syncUserMetrics } = await import("@/lib/fitbit/sync-metrics"); await syncUserMetrics(TEST_USER_ID); // Re-fetch the same window with a corrected value (same anchor → same key). - stubGoogleHealth({ weight: [point(81.2)] }); + stubFitbit({ "body/log/weight": body(81.2) }); await syncUserMetrics(TEST_USER_ID); const prisma = getPrismaClient(); @@ -171,15 +170,10 @@ describe("syncUserMetrics — integration", () => { }, }); - stubGoogleHealth({ - weight: [ - { - weight: { - kilograms: 80.5, - sample_time: { physical_time: "2026-05-10T07:00:00.000Z" }, - }, - }, - ], + stubFitbit({ + "body/log/weight": { + weight: [{ date: "2026-05-10", time: "07:00:00", weight: 80.5 }], + }, }); const { syncUserMetrics } = await import("@/lib/fitbit/sync-metrics"); await syncUserMetrics(TEST_USER_ID);