mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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.
637 lines
21 KiB
TypeScript
637 lines
21 KiB
TypeScript
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");
|
||
});
|
||
});
|