Files
HealthLog/src/lib/fitbit/__tests__/client.test.ts
T
Marc-André Bombeck 9c63d3ce20 chore(release): v1.28.25 — every integration held to the same standard
Platform-wide hardening: the failure classes found live this week hunted down
across every integration.

Fitbit carried the same tombstone wedge fixed for Google — probe now matches
soft-deleted rows and the update resurrects them; the comment describing a
partial unique index that never existed is corrected. Sleep segment ids are
stable across re-scoring for Withings (session+segment-start), WHOOP/Polar
(one segment per stage, no positional index) and Oura (run-start keyed), each
with a session-scoped sweep that soft-deletes rows a re-score orphaned — so
legacy and affected nights heal as they are re-read.

One platform rule for deleted rows: source-owned readings resurrect on
re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV
re-imports, Apple stats day-totals); Apple sample-grain deletions stick per
the paired-client contract, and the mood bulk route's tombstone match is now
a true no-op reported as duplicate. Import counts reconcile against actual
createMany counts.

Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so
an expired connection parks instead of stamping success hourly; backfill and
sleep-repair markers stamp only after a clean pass (pg-boss retries are now
real); failed measurement writes hold the watermark; no-op updates are
skipped; mapping.md tells the current truth.

Scale: doctor-report reads only consumed columns and enabled sections;
exports and backups keyset-paginate; achievements tally via SQL buckets;
series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear
two-pointer; distinct-type scans run server-side; the graded-series cold
fallback is bounded; the fitbit defer tracker loops instead of spreading.
2026-07-11 01:54:12 +02:00

637 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
FITBIT_API_BASE,
FITBIT_FIELD_MAP,
FITBIT_OAUTH_SCOPE,
exchangeCode,
fetchSleepRange,
fetchSpo2Range,
fetchWeightRange,
generatePkcePair,
getAuthorizationUrl,
mapActiveCalories,
mapBodyFat,
mapDistance,
mapFitbitSleepStage,
mapFitbitSportType,
mapFloors,
mapHeartRateVariability,
mapOxygenSaturation,
mapRespiratoryRate,
mapRestingHeartRate,
mapSleepSession,
mapSteps,
mapVo2Max,
mapWeight,
mapWorkout,
parseVo2Max,
readActivityList,
readSleepSessions,
refreshAccessToken,
resolveFitbitUserId,
} from "../client";
const CREDS = { clientId: "cid", clientSecret: "csecret" };
/** Stub global fetch with a queue of `{ status, body }` responses. */
function installFetchMock(pages: Array<{ status: number; body: unknown }>) {
let i = 0;
const fetchMock = vi.fn(async () => {
const page = pages[Math.min(i, pages.length - 1)]!;
i += 1;
return {
status: page.status,
json: async () => page.body,
};
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
// The redirect_uri allowlist requires a configured, absolute https app origin.
const PRIOR_APP_URL = process.env.NEXT_PUBLIC_APP_URL;
beforeEach(() => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test";
});
afterEach(() => {
if (PRIOR_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL;
else process.env.NEXT_PUBLIC_APP_URL = PRIOR_APP_URL;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("generatePkcePair", () => {
it("mints a high-entropy verifier and the matching S256 challenge", () => {
const { verifier, challenge } = generatePkcePair();
// 64 random bytes → 86 base64url chars; inside Fitbit's 43–128 range.
expect(verifier.length).toBeGreaterThanOrEqual(43);
expect(verifier.length).toBeLessThanOrEqual(128);
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
// challenge = BASE64URL(SHA256(verifier)).
const expected = createHash("sha256").update(verifier).digest("base64url");
expect(challenge).toBe(expected);
});
it("mints a fresh pair each call", () => {
expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier);
});
});
describe("getAuthorizationUrl", () => {
it("builds the classic Fitbit authorize URL with PKCE S256", () => {
const url = getAuthorizationUrl("nonce123", CREDS, "chal-xyz");
expect(url).toContain("www.fitbit.com/oauth2/authorize");
expect(url).toContain("response_type=code");
expect(url).toContain("client_id=cid");
expect(url).toContain("state=nonce123");
const parsed = new URL(url);
expect(parsed.searchParams.get("code_challenge")).toBe("chal-xyz");
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
expect(parsed.searchParams.get("scope")).toBe(FITBIT_OAUTH_SCOPE);
});
it("requests the classic self-serve scopes and omits temperature", () => {
const scopes = FITBIT_OAUTH_SCOPE.split(" ");
expect(scopes).toContain("activity");
expect(scopes).toContain("heartrate");
expect(scopes).toContain("sleep");
expect(scopes).toContain("weight");
expect(scopes).toContain("oxygen_saturation");
expect(scopes).toContain("respiratory_rate");
expect(scopes).toContain("cardio_fitness");
expect(scopes).toContain("profile");
// temperature deliberately omitted — the classic skin-temp reading is a
// baseline delta with no honest absolute slot.
expect(FITBIT_OAUTH_SCOPE).not.toContain("temperature");
expect(FITBIT_OAUTH_SCOPE).not.toContain("nutrition");
expect(FITBIT_OAUTH_SCOPE).not.toContain("location");
});
it("carries the configured app origin in the redirect_uri", () => {
const url = getAuthorizationUrl("nonce123", CREDS, "c");
const redirect = new URL(url).searchParams.get("redirect_uri");
expect(redirect).toBe("https://app.example.test/api/fitbit/callback");
});
});
describe("redirect_uri allowlist", () => {
const PRIOR_REDIRECT = process.env.FITBIT_REDIRECT_URI;
afterEach(() => {
if (PRIOR_REDIRECT === undefined) delete process.env.FITBIT_REDIRECT_URI;
else process.env.FITBIT_REDIRECT_URI = PRIOR_REDIRECT;
});
it("rejects a non-https app origin", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://app.example.test";
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(/must be https/);
});
it("allows http on localhost (dev)", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
const url = getAuthorizationUrl("n", CREDS, "c");
expect(new URL(url).searchParams.get("redirect_uri")).toBe(
"http://localhost:3000/api/fitbit/callback",
);
});
it("rejects an explicit redirect that targets the wrong path", () => {
process.env.FITBIT_REDIRECT_URI = "https://app.example.test/evil";
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(
/must target \/api\/fitbit\/callback/,
);
});
it("rejects an explicit redirect whose origin differs from NEXT_PUBLIC_APP_URL", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test";
process.env.FITBIT_REDIRECT_URI =
"https://attacker.example.test/api/fitbit/callback";
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(
/does not match/,
);
});
});
describe("token exchange + refresh", () => {
it("exchanges an authorization code WITH the PKCE verifier via Basic auth", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: {
access_token: "at",
refresh_token: "rt",
expires_in: 28800,
scope: "activity sleep weight",
user_id: "ABC123",
},
},
]);
const tok = await exchangeCode("code", "verifier-123", CREDS);
expect(tok.access_token).toBe("at");
expect(tok.refresh_token).toBe("rt");
expect(tok.expires_in).toBe(28800);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ headers: Record<string, string>; body: string },
];
expect(url).toContain("api.fitbit.com/oauth2/token");
expect(init.body).toContain("grant_type=authorization_code");
expect(init.body).toContain("code_verifier=verifier-123");
const expected = `Basic ${Buffer.from("cid:csecret").toString("base64")}`;
expect(init.headers.Authorization).toBe(expected);
});
it("refreshes and returns the ROTATED refresh token (classic rotates)", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: {
access_token: "at2",
refresh_token: "rt2",
expires_in: 28800,
},
},
]);
const tok = await refreshAccessToken("rt1", CREDS);
expect(tok.access_token).toBe("at2");
// Classic Fitbit rotates: a fresh refresh token is returned every time.
expect(tok.refresh_token).toBe("rt2");
const [, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ body: string },
];
expect(init.body).toContain("grant_type=refresh_token");
expect(init.body).toContain("refresh_token=rt1");
// The grant's scope is preserved — no scope param re-sent.
expect(init.body).not.toContain("scope=");
});
it("throws a classified FitbitApiError on a 401 token response", async () => {
installFetchMock([
{ status: 401, body: { errors: [{ errorType: "invalid_grant" }] } },
]);
await expect(exchangeCode("bad", "v", CREDS)).rejects.toMatchObject({
name: "FitbitApiError",
classification: "reauth_required",
});
});
});
describe("fetchProfile + resolveFitbitUserId", () => {
it("resolves the external user id from the profile encodedId", () => {
expect(resolveFitbitUserId({ user: { encodedId: "ABC123" } })).toBe(
"ABC123",
);
expect(resolveFitbitUserId({})).toBe("me");
expect(resolveFitbitUserId({ user: {} })).toBe("me");
});
});
describe("date-range fetchers", () => {
it("encodes the weight endpoint path with the metric Accept-Language", async () => {
const fetchMock = installFetchMock([{ status: 200, body: { weight: [] } }]);
await fetchWeightRange(
"at",
new Date("2026-05-01T00:00:00Z"),
new Date("2026-05-30T00:00:00Z"),
);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ headers: Record<string, string> },
];
expect(url).toBe(
`${FITBIT_API_BASE}/1/user/-/body/log/weight/date/2026-05-01/2026-05-30.json`,
);
expect(init.headers["Accept-Language"]).toBe("en_GB");
expect(init.headers.Authorization).toBe("Bearer at");
});
it("encodes the sleep endpoint on the 1.2 version path", async () => {
const fetchMock = installFetchMock([{ status: 200, body: { sleep: [] } }]);
await fetchSleepRange(
"at",
new Date("2026-05-01T00:00:00Z"),
new Date("2026-05-30T00:00:00Z"),
);
const [url] = fetchMock.mock.calls[0] as unknown as [string];
expect(url).toBe(
`${FITBIT_API_BASE}/1.2/user/-/sleep/date/2026-05-01/2026-05-30.json`,
);
});
it("throws a classified FitbitApiError on a 403 page", async () => {
installFetchMock([{ status: 403, body: {} }]);
await expect(
fetchSpo2Range("at", new Date(), new Date()),
).rejects.toMatchObject({
name: "FitbitApiError",
classification: "reauth_required",
httpStatus: 403,
});
});
});
describe("metric mappers (classic shapes)", () => {
it("maps weight in kg, anchoring the externalId on the logId", () => {
const out = mapWeight({
weight: [
{
date: "2026-05-10",
time: "07:30:00",
weight: 81.46,
logId: 1551080804000,
source: "Aria",
},
],
});
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ type: "WEIGHT", value: 81.46, unit: "kg" });
expect(out[0]!.measuredAt.toISOString()).toBe("2026-05-10T07:30:00.000Z");
expect(out[0]!.fieldTag).toBe("1551080804000:weight");
});
it("maps body fat percentage from the fat array", () => {
const out = mapBodyFat({
fat: [{ date: "2026-05-10", time: "07:30:00", fat: 18.2, logId: 42 }],
});
expect(out[0]).toMatchObject({ type: "BODY_FAT", value: 18.2, unit: "%" });
expect(out[0]!.fieldTag).toBe("42:body_fat");
});
it("maps daily SpO2 from value.avg with a day-keyed externalId (bare array)", () => {
const out = mapOxygenSaturation([
{ dateTime: "2026-05-10", value: { avg: 97.4, min: 94, max: 100 } },
]);
expect(out[0]).toMatchObject({
type: "OXYGEN_SATURATION",
value: 97.4,
unit: "%",
});
expect(out[0]!.fieldTag).toBe("2026-05-10:spo2");
});
it("maps HRV dailyRmssd into the canonical HEART_RATE_VARIABILITY slot", () => {
const out = mapHeartRateVariability({
hrv: [
{ dateTime: "2026-05-10", value: { dailyRmssd: 42.7, deepRmssd: 50 } },
],
});
expect(out[0]!.type).toBe("HEART_RATE_VARIABILITY");
expect(out[0]).toMatchObject({ value: 42.7, unit: "ms" });
expect(out[0]!.fieldTag).toBe("2026-05-10:hrv");
});
it("maps resting HR from the activities-heart value.restingHeartRate", () => {
const out = mapRestingHeartRate({
"activities-heart": [
{ dateTime: "2026-05-10", value: { restingHeartRate: 54 } },
],
});
expect(out[0]).toMatchObject({ type: "RESTING_HEART_RATE", value: 54 });
});
it("maps respiratory rate from value.breathingRate", () => {
const out = mapRespiratoryRate({
br: [{ dateTime: "2026-05-10", value: { breathingRate: 17.8 } }],
});
expect(out[0]).toMatchObject({
type: "RESPIRATORY_RATE",
value: 17.8,
unit: "breaths/min",
});
});
it("drops a garbage / non-positive / absent reading rather than minting a row", () => {
expect(
mapWeight({ weight: [{ date: "2026-05-10", weight: 0 }] }),
).toHaveLength(0);
expect(mapWeight({ weight: [] })).toHaveLength(0);
expect(mapWeight({})).toHaveLength(0);
expect(
mapOxygenSaturation([{ dateTime: "2026-05-10", value: {} }]),
).toHaveLength(0);
});
});
describe("VO2 max", () => {
it("parses a single numeric string and a range midpoint", () => {
expect(parseVo2Max("45")).toBe(45);
expect(parseVo2Max("44-48")).toBe(46);
expect(parseVo2Max(50)).toBe(50);
expect(parseVo2Max("not-a-number")).toBeNull();
});
it("maps cardioScore value.vo2Max (range → midpoint) with a day-keyed stats externalId", () => {
const out = mapVo2Max({
cardioScore: [
{ dateTime: "2026-05-10", value: { vo2Max: "44-48" } },
{ dateTime: "2026-05-11", value: { vo2Max: "45" } },
],
});
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({ type: "VO2_MAX", value: 46 });
expect(out[0]!.cumulativeDaily).toBe(true);
expect(out[0]!.fieldTag).toBe("vo2_max:2026-05-10");
expect(out[1]!.value).toBe(45);
});
});
describe("activity mappers (cumulative daily)", () => {
it("maps steps from the string value, preserving a zero, with a day-keyed externalId", () => {
const out = mapSteps({
"activities-steps": [
{ dateTime: "2026-05-10", value: "8421" },
{ dateTime: "2026-05-11", value: "0" },
],
});
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
type: "ACTIVITY_STEPS",
value: 8421,
unit: "steps",
cumulativeDaily: true,
});
expect(out[0]!.fieldTag).toBe("steps:2026-05-10");
// A rest day of 0 steps is real data.
expect(out[1]!.value).toBe(0);
});
it("maps distance km → metres", () => {
const out = mapDistance({
"activities-distance": [{ dateTime: "2026-05-10", value: "6.2" }],
});
expect(out[0]).toMatchObject({
type: "WALKING_RUNNING_DISTANCE",
value: 6200,
unit: "m",
});
});
it("maps the ACTIVE-calories portion into ACTIVE_ENERGY_BURNED", () => {
const out = mapActiveCalories({
"activities-activityCalories": [{ dateTime: "2026-05-10", value: "540" }],
});
expect(out[0]).toMatchObject({
type: "ACTIVE_ENERGY_BURNED",
value: 540,
unit: "kcal",
});
});
it("maps floors and preserves a zero", () => {
const out = mapFloors({
"activities-floors": [
{ dateTime: "2026-05-10", value: "12" },
{ dateTime: "2026-05-11", value: "0" },
],
});
expect(out[0]).toMatchObject({ type: "FLIGHTS_CLIMBED", value: 12 });
expect(out[1]!.value).toBe(0);
});
});
describe("sleep mapping (1.2 levels.data)", () => {
it("harmonises classic Fitbit level labels onto the shared SleepStage enum", () => {
expect(mapFitbitSleepStage("light")).toBe("CORE"); // Fitbit light ↔ Apple core
expect(mapFitbitSleepStage("deep")).toBe("DEEP");
expect(mapFitbitSleepStage("rem")).toBe("REM");
expect(mapFitbitSleepStage("wake")).toBe("AWAKE");
expect(mapFitbitSleepStage("awake")).toBe("AWAKE");
expect(mapFitbitSleepStage("restless")).toBe("AWAKE");
expect(mapFitbitSleepStage("asleep")).toBe("ASLEEP");
expect(mapFitbitSleepStage("snoring")).toBeNull();
expect(mapFitbitSleepStage(42)).toBeNull();
});
it("maps a session into per-SEGMENT rows with measuredAt = segment END (real timeline)", () => {
const sessions = readSleepSessions({
sleep: [
{
logId: 999,
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: 1800,
},
{
dateTime: "2026-05-10T22:30:00.000",
level: "deep",
seconds: 3600,
},
{
dateTime: "2026-05-10T23:30:00.000",
level: "rem",
seconds: 2700,
},
],
},
},
],
});
expect(sessions).toHaveLength(1);
const out = mapSleepSession(sessions[0]!);
// One row per segment.
expect(out).toHaveLength(3);
expect(out.every((m) => m.type === "SLEEP_DURATION")).toBe(true);
expect(out.every((m) => m.unit === "minutes")).toBe(true);
const core = out.find((m) => m.sleepStage === "CORE")!;
expect(core.value).toBe(30); // 1800s → 30 min
// measuredAt = segment START + seconds = END (local instant).
expect(core.measuredAt.getTime()).toBe(
new Date("2026-05-10T22:30:00.000").getTime(),
);
const deep = out.find((m) => m.sleepStage === "DEEP")!;
expect(deep.value).toBe(60);
const rem = out.find((m) => m.sleepStage === "REM")!;
expect(rem.value).toBe(45);
// Every fieldTag distinct (logId anchor + stage + segment index).
const tags = out.map((m) => m.fieldTag);
expect(new Set(tags).size).toBe(tags.length);
expect(deep.fieldTag).toBe("999:sleep_deep:1");
});
it("yields nothing for a session with no parseable segments", () => {
expect(mapSleepSession({ levels: { data: [] } })).toHaveLength(0);
expect(mapSleepSession({})).toHaveLength(0);
});
it("anchors a near-midnight segment END to the user's timezone, not the process zone", () => {
// A Berlin user (UTC+2 in May / CEST) whose last segment ENDS at 00:30
// local on 2026-05-11. The offset-less wall clock `2026-05-11T00:30:00`
// must resolve to 22:30 UTC on 2026-05-10 — NOT 00:30 UTC (which a bare
// `new Date(iso)` in a UTC process would produce, flipping the wake-day).
const sessions = readSleepSessions({
sleep: [
{
logId: 4242,
startTime: "2026-05-10T23:00:00.000",
endTime: "2026-05-11T00:30:00.000",
levels: {
data: [
{
dateTime: "2026-05-11T00:00:00.000",
level: "deep",
seconds: 1800, // ends 00:30 Berlin local
},
],
},
},
],
});
const out = mapSleepSession(sessions[0]!, "Europe/Berlin");
expect(out).toHaveLength(1);
// 00:30 CEST (UTC+2) → 22:30 UTC on the PRIOR civil day.
expect(out[0]!.measuredAt.toISOString()).toBe("2026-05-10T22:30:00.000Z");
});
it("anchors a logId-less session anchor against the user's timezone", () => {
const sessions = readSleepSessions({
sleep: [
{
// No logId → the anchor falls back to the START instant (the
// session's identity; the end moves when Fitbit re-scores a night),
// which must also be tz-resolved so the externalId is stable across
// syncs. endTime is present to prove start wins the fallback.
startTime: "2026-05-10T23:00:00.000",
endTime: "2026-05-11T00:30:00.000",
levels: {
data: [
{
dateTime: "2026-05-11T00:00:00.000",
level: "rem",
seconds: 1800,
},
],
},
},
],
});
const out = mapSleepSession(sessions[0]!, "Europe/Berlin");
expect(out).toHaveLength(1);
// anchor = start instant ISO (UTC) → fieldTag starts with that instant.
expect(out[0]!.fieldTag).toBe("2026-05-10T21:00:00.000Z:sleep_rem:0");
});
});
describe("workout mapping (activities list)", () => {
it("maps an activity-list entry into a Workout keyed on the logId", () => {
const entries = readActivityList({
activities: [
{
logId: 123456,
activityName: "Run",
startTime: "2026-05-10T07:00:00.000",
duration: 45 * 60 * 1000,
calories: 410,
distance: 7.8, // km
averageHeartRate: 148,
},
],
});
expect(entries).toHaveLength(1);
const w = mapWorkout(entries[0]!);
expect(w).not.toBeNull();
expect(w).toMatchObject({
externalId: "123456",
sportType: "running",
durationSec: 45 * 60,
totalEnergyKcal: 410,
totalDistanceM: 7800, // km → m
avgHeartRate: 148,
maxHeartRate: null,
minHeartRate: null,
});
expect(w!.startedAt.getTime()).toBe(
new Date("2026-05-10T07:00:00.000").getTime(),
);
});
it("falls back to a start-anchored externalId + 'other' sport when fields are absent", () => {
const w = mapWorkout({
startTime: "2026-05-10T07:00:00.000",
duration: 30 * 60 * 1000,
});
expect(w!.externalId).toMatch(/^exercise:/);
expect(w!.sportType).toBe("other");
expect(w!.totalEnergyKcal).toBeNull();
expect(w!.avgHeartRate).toBeNull();
});
it("returns null for an entry with no usable time span", () => {
expect(mapWorkout({ startTime: "2026-05-10T07:00:00.000" })).toBeNull();
expect(mapWorkout({ duration: 1000 })).toBeNull();
expect(mapWorkout({})).toBeNull();
});
it("resolves activity names to canonical sport labels", () => {
expect(mapFitbitSportType("Walk")).toBe("walking");
expect(mapFitbitSportType("biking")).toBe("cycling");
expect(mapFitbitSportType("weights")).toBe("strength");
expect(mapFitbitSportType("unknown-sport")).toBe("other");
expect(mapFitbitSportType("")).toBe("other");
});
});
describe("FITBIT_FIELD_MAP", () => {
it("documents a type + unit for every launch metric", () => {
for (const [, entry] of Object.entries(FITBIT_FIELD_MAP)) {
expect(typeof entry.type).toBe("string");
expect(entry.type.length).toBeGreaterThan(0);
expect(typeof entry.unit).toBe("string");
}
expect(FITBIT_FIELD_MAP.weight!.type).toBe("WEIGHT");
expect(FITBIT_FIELD_MAP.vo2Max!.type).toBe("VO2_MAX");
});
});