mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The v1.20.0 transport switch retargeted the Fitbit client from the Google Health API onto the classic Fitbit Web API, but the two sync integration tests still stubbed fetch with Google-shaped dataPoints responses. The mocks no longer matched the endpoints the client reads, so every ingest assertion saw zero rows. Rewrite the fetch-mock layer in both files to key on the classic endpoint paths and return classic-shaped JSON the real mappers parse: - body/log/weight, activities/heart for the metrics sync - activities/steps for the cumulative-activity sync - 1.2 sleep log levels.data for the per-stage sleep sync - activities/list for the workout sync The ingest contract is preserved (row counts, dedup, source priority, sleep stages, daily-total overwrite). Two assertions move to their classic equivalents: the workout externalId now anchors on the numeric logId, and the workout test pins the user to UTC so the offset-less local wall-clock timestamps the classic sleep/activity logs emit resolve to the same instants the cross-source picker buckets on.
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
/**
|
|
* v1.20.0 — Fitbit activity / sleep / workout sync end-to-end (classic API).
|
|
*
|
|
* 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.
|
|
* - 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",
|
|
// 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: "fb-w5",
|
|
accessToken: encrypt("access-token"),
|
|
refreshToken: encrypt("refresh-token"),
|
|
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
scope: "activity sleep",
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
/**
|
|
* 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) => {
|
|
for (const [needle, body] of Object.entries(byPath)) {
|
|
if (url.includes(needle)) {
|
|
return { status: 200, json: async () => body };
|
|
}
|
|
}
|
|
return { status: 200, json: async () => ({}) };
|
|
}),
|
|
);
|
|
}
|
|
|
|
describe("syncUserActivity — cumulative overwrite", () => {
|
|
it("mints a stats: externalId and overwrites the same day on re-sync (no duplicate)", async () => {
|
|
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);
|
|
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.
|
|
stubFitbit({
|
|
"activities/steps": {
|
|
"activities-steps": [{ dateTime: "2026-05-10", value: "9123" }],
|
|
},
|
|
});
|
|
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 () => {
|
|
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);
|
|
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 () => {
|
|
// 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 = {
|
|
logId: 555001,
|
|
startTime: "2026-05-10T22:00:00.000",
|
|
endTime: "2026-05-11T06:00:00.000",
|
|
levels: {
|
|
data: [
|
|
{
|
|
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 },
|
|
],
|
|
},
|
|
};
|
|
stubFitbit({ "sleep/date": { 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 () => {
|
|
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);
|
|
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({
|
|
// Classic externalId = String(logId).
|
|
externalId: "90001",
|
|
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,
|
|
},
|
|
});
|
|
|
|
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);
|
|
|
|
// 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");
|
|
});
|
|
});
|