mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge Fitbit integration test mocks for the classic Web API
Rewrite the two Fitbit integration tests' fetch mocks to the classic Fitbit Web API shapes the v1.20.0 client now reads (body log, activity, 1.2 sleep levels, activities/list), so the sync ingests the expected rows again. Anchor the workout externalId on the numeric logId and pin the tz-sensitive test user to UTC; all counts, dedup, stage and source- priority intents are preserved.
This commit is contained in:
commit
755f7e101b
2 files changed
+125
-116
No files matched your search
@@ -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<string, unknown[]>) {
|
||||
/**
|
||||
* 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<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 }) };
|
||||
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);
|
||||
|
||||
@@ -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/-/<resource>/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<string, unknown[]>) {
|
||||
function stubFitbit(byPath: 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 }),
|
||||
};
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user