fix(polar): use AccessLink collection endpoints

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 14:25:24 +02:00
1 parent 74a386fcfd
commit b9425de40e
4 files changed
+159 -75

No files matched your search

+102 -20
View File
@@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
POLAR_OAUTH_SCOPE,
exchangeCode,
fetchActivities,
fetchCardioLoads,
fetchNightlyRecharges,
fetchSleeps,
fetchSpo2,
getAuthorizationUrl,
getPolarCredentials,
@@ -99,6 +101,12 @@ describe("exchangeCode", () => {
const tok = await exchangeCode("code123", CREDS);
expect(tok.access_token).toBe("tok");
expect(tok.x_user_id).toBe(42);
expect(fetchMock.mock.calls[0]![0]).toBe(
"https://polarremote.com/v2/oauth2/token",
);
expect(fetchMock.mock.calls[0]![1]).toEqual(
expect.objectContaining({ method: "POST" }),
);
const [, init] = fetchMock.mock.calls[0]!;
const headers = init!.headers as Record<string, string>;
expect(headers.Authorization).toBe(
@@ -120,9 +128,13 @@ describe("registerUser", () => {
installFetchMock([{ status: 409, body: {} }]);
await expect(registerUser("tok", "42")).resolves.toBeUndefined();
});
it("succeeds on a 200", async () => {
installFetchMock([{ status: 200, body: {} }]);
it("posts to the documented registration path", async () => {
const fetchMock = installFetchMock([{ status: 200, body: {} }]);
await expect(registerUser("tok", "42")).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith(
"https://www.polaraccesslink.com/v3/users",
expect.objectContaining({ method: "POST" }),
);
});
it("throws on a 500", async () => {
installFetchMock([{ status: 500 }]);
@@ -132,16 +144,55 @@ describe("registerUser", () => {
});
});
describe("Polar AccessLink request paths", () => {
it.each([
{
resource: "nightly recharge",
request: () => fetchNightlyRecharges("tok"),
url: "https://www.polaraccesslink.com/v3/users/nightly-recharge",
},
{
resource: "sleep",
request: () => fetchSleeps("tok"),
url: "https://www.polaraccesslink.com/v3/users/sleep",
},
{
resource: "daily activity",
request: () => fetchActivities("tok"),
url: "https://www.polaraccesslink.com/v3/users/activities",
},
{
resource: "cardio load",
request: () => fetchCardioLoads("tok"),
url: "https://www.polaraccesslink.com/v3/users/cardio-load",
},
{
resource: "SpO2",
request: () => fetchSpo2("tok"),
url: "https://www.polaraccesslink.com/v3/users/biosensing/spo2",
},
])("uses the documented GET path for $resource", async ({ request, url }) => {
const fetchMock = installFetchMock([{ status: 204 }]);
await request();
expect(fetchMock).toHaveBeenCalledWith(
url,
expect.objectContaining({ method: "GET" }),
);
});
});
describe("fetchNightlyRecharges", () => {
it("returns [] on a 204 No Content", async () => {
installFetchMock([{ status: 204 }]);
expect(await fetchNightlyRecharges("tok", "42")).toEqual([]);
expect(await fetchNightlyRecharges("tok")).toEqual([]);
});
it("unwraps the recharges array", async () => {
installFetchMock([
{ status: 200, body: { recharges: [{ date: "2026-06-10" }] } },
]);
const r = await fetchNightlyRecharges("tok", "42");
const r = await fetchNightlyRecharges("tok");
expect(r).toHaveLength(1);
expect(r[0]!.date).toBe("2026-06-10");
});
@@ -364,8 +415,8 @@ describe("mapSleep", () => {
describe("mapActivity — distance", () => {
it("maps distance_from_steps to WALKING_RUNNING_DISTANCE in metres", () => {
const a: PolarActivity = {
date: "2026-06-10",
"active-steps": 8000,
start_time: "2026-06-10T00:00:00",
steps: 8000,
distance_from_steps: 4590.53,
};
const dist = mapActivity(a).find(
@@ -393,39 +444,67 @@ describe("mapCardioLoad", () => {
});
describe("mapSpo2", () => {
it("maps blood_oxygen_percentage to OXYGEN_SATURATION (percent)", () => {
const r: PolarSpo2 = { date: "2026-06-10", blood_oxygen_percentage: 96.4 };
it("maps the documented SpO2 fields to an exact-timestamp reading", () => {
const r: PolarSpo2 = {
test_time: 1_781_059_600,
blood_oxygen_percent: 96,
};
const mapped = mapSpo2(r);
expect(mapped[0]?.type).toBe("OXYGEN_SATURATION");
expect(mapped[0]?.value).toBe(96.4);
expect(mapped[0]?.value).toBe(96);
expect(mapped[0]?.unit).toBe("%");
expect(mapped[0]?.measuredAt.toISOString()).toBe(
"2026-06-10T02:46:40.000Z",
);
});
it("rejects out-of-range readings", () => {
expect(mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 0 })).toEqual(
expect(mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 0 })).toEqual(
[],
);
expect(
mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 120 }),
mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 120 }),
).toEqual([]);
});
});
describe("fetchCardioLoads", () => {
it("unwraps the cardio-loads array", async () => {
it("returns the documented top-level cardio-load array", async () => {
installFetchMock([
{ status: 200, body: { "cardio-loads": [{ date: "2026-06-10" }] } },
{ status: 200, body: [{ date: "2026-06-10", cardio_load: 123.45 }] },
]);
const r = await fetchCardioLoads("tok", "42");
const r = await fetchCardioLoads("tok");
expect(r).toHaveLength(1);
});
});
describe("fetchSpo2", () => {
it("unwraps the tests array", async () => {
it("returns the documented top-level SpO2 array", async () => {
installFetchMock([
{ status: 200, body: { tests: [{ date: "2026-06-10" }] } },
{
status: 200,
body: [{ test_time: 1_781_059_600, blood_oxygen_percent: 96 }],
},
]);
const r = await fetchSpo2("tok", "42");
const r = await fetchSpo2("tok");
expect(r).toHaveLength(1);
});
});
describe("fetchActivities", () => {
it("returns the documented top-level activity array", async () => {
installFetchMock([
{
status: 200,
body: [
{
start_time: "2026-06-10T00:00:00",
active_calories: 540.6,
steps: 8123,
},
],
},
]);
const r = await fetchActivities("tok");
expect(r).toHaveLength(1);
});
});
@@ -433,9 +512,9 @@ describe("fetchSpo2", () => {
describe("mapActivity", () => {
it("maps steps and active energy to canonical MeasurementType enum values", () => {
const a: PolarActivity = {
date: "2026-06-10",
"active-steps": 8123,
"active-calories": 540.6,
start_time: "2026-06-10T00:00:00",
steps: 8123,
active_calories: 540.6,
calories: 2400,
};
const mapped = mapActivity(a);
@@ -445,6 +524,9 @@ describe("mapActivity", () => {
540.6,
);
});
it("ignores an activity when Polar omits its optional start time", () => {
expect(mapActivity({})).toEqual([]);
});
});
describe("POLAR_OAUTH_SCOPE", () => {
+12
View File
@@ -168,6 +168,18 @@ describe("syncUserPolar", () => {
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("authenticates all collection reads with the access token only", async () => {
getConnMock.mockResolvedValue(CONN);
await syncUserPolar("u1");
expect(fetchRechargesMock).toHaveBeenCalledWith("tok");
expect(fetchSleepsMock).toHaveBeenCalledWith("tok");
expect(fetchActivitiesMock).toHaveBeenCalledWith("tok");
expect(fetchCardioLoadsMock).toHaveBeenCalledWith("tok");
expect(fetchSpo2Mock).toHaveBeenCalledWith("tok");
});
it("maps a recharge RECOVERY_SCORE row with source POLAR + stable externalId", async () => {
getConnMock.mockResolvedValue(CONN);
fetchRechargesMock.mockResolvedValue([
+40 -50
View File
@@ -13,9 +13,9 @@
* - Polar access tokens DO NOT expire and there is NO refresh token. The
* `User.polarRefreshTokenEncrypted` column stays null; `getValidToken`
* never refreshes. A revoked grant surfaces as a 401 on the next data read.
* - The token response carries `x_user_id` — Polar's numeric member id — which
* every data path needs (`/v3/users/{userId}/...`). It is persisted as
* `User.polarUserIdEncrypted` at connect time.
* - The token response carries `x_user_id` — Polar's numeric member id. It
* remains necessary for user registration and direct user-information
* lookups, while collection reads identify the user from the access token.
* - The client must register the user once (`POST /v3/users`) before data
* reads work; a 409 from registration means "already registered" and is
* treated as success.
@@ -198,11 +198,11 @@ export interface PolarSleep {
heart_rate_samples?: Record<string, number>;
}
/** One Polar daily-activity summary. */
/** One current AccessLink daily-activity summary. */
export interface PolarActivity {
date: string;
"active-calories"?: number | null;
"active-steps"?: number | null;
start_time?: string;
active_calories?: number | null;
steps?: number | null;
calories?: number | null;
/** Estimated walking/running distance derived from steps, in METRES. */
distance_from_steps?: number | null;
@@ -223,11 +223,11 @@ export interface PolarCardioLoad {
cardio_load_ratio?: number | null;
}
/** One Polar SpO2 (Elixir pulse-ox) test result. `blood_oxygen_percentage` is
* the recorded SpO2 reading in percent (0..100). */
/** One Polar SpO2 (Elixir pulse-ox) test result. `test_time` is a Unix
* timestamp in seconds; `blood_oxygen_percent` is the recorded percentage. */
export interface PolarSpo2 {
date: string;
blood_oxygen_percentage?: number | null;
test_time?: number;
blood_oxygen_percent?: number | null;
}
interface CollectionResult<T> {
@@ -238,7 +238,7 @@ async function fetchCollection<T>(
path: string,
accessToken: string,
verb: string,
recordsKey: string,
recordsKey?: string,
): Promise<T[]> {
const start = performance.now();
const res = await safeFetch(`${POLAR_API_BASE}${path}`, {
@@ -267,73 +267,63 @@ async function fetchCollection<T>(
// 204 No Content — a window with no records. Polar returns an empty body.
if (res.status === 204) return [];
const json = (await res.json().catch(() => null)) as
CollectionResult<T> | Record<string, T[]> | null;
| CollectionResult<T>
| Record<string, T[]>
| T[]
| null;
if (!json) return [];
if (Array.isArray(json)) return json;
if (!recordsKey) return [];
const list = (json as Record<string, unknown>)[recordsKey];
return Array.isArray(list) ? (list as T[]) : [];
}
export function fetchNightlyRecharges(
accessToken: string,
userId: string,
): Promise<PolarNightlyRecharge[]> {
return fetchCollection<PolarNightlyRecharge>(
`/v3/users/${encodeURIComponent(userId)}/nightly-recharge`,
"/v3/users/nightly-recharge",
accessToken,
"fetchNightlyRecharges",
"recharges",
);
}
export function fetchSleeps(
accessToken: string,
userId: string,
): Promise<PolarSleep[]> {
export function fetchSleeps(accessToken: string): Promise<PolarSleep[]> {
return fetchCollection<PolarSleep>(
`/v3/users/${encodeURIComponent(userId)}/sleep`,
"/v3/users/sleep",
accessToken,
"fetchSleeps",
"nights",
);
}
export function fetchActivities(
accessToken: string,
userId: string,
): Promise<PolarActivity[]> {
export function fetchActivities(accessToken: string): Promise<PolarActivity[]> {
return fetchCollection<PolarActivity>(
`/v3/users/${encodeURIComponent(userId)}/activity-transactions`,
"/v3/users/activities",
accessToken,
"fetchActivities",
"activity-log",
);
}
/** Training Load Pro collection — `cardio_load` strain figures for the recent
* window. Records live under the `cardio-loads` key. */
* window. */
export function fetchCardioLoads(
accessToken: string,
userId: string,
): Promise<PolarCardioLoad[]> {
return fetchCollection<PolarCardioLoad>(
`/v3/users/${encodeURIComponent(userId)}/cardio-load`,
"/v3/users/cardio-load",
accessToken,
"fetchCardioLoads",
"cardio-loads",
);
}
/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. Records
* live under the `tests` key. */
export function fetchSpo2(
accessToken: string,
userId: string,
): Promise<PolarSpo2[]> {
/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. */
export function fetchSpo2(accessToken: string): Promise<PolarSpo2[]> {
return fetchCollection<PolarSpo2>(
`/v3/users/${encodeURIComponent(userId)}/spo2-tests`,
"/v3/users/biosensing/spo2",
accessToken,
"fetchSpo2",
"tests",
);
}
@@ -567,16 +557,15 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] {
return out;
}
/** Map one Polar daily-activity record: `ACTIVITY_STEPS` +
* `ACTIVE_ENERGY_BURNED` where Polar reports them. Steps live under
* `active-steps`; the ACTIVE energy portion under `active-calories` (NOT the
* total `calories`, which includes BMR — matching the Fitbit active-only
* convention). */
/** Map one current AccessLink daily-activity record: `steps` +
* `active_calories` where Polar reports them. The active energy portion is
* used instead of total `calories`, which includes BMR. */
export function mapActivity(a: PolarActivity): MappedMeasurement[] {
const measuredAt = canonicalDailyTimestamp(a.date);
if (typeof a.start_time !== "string") return [];
const measuredAt = canonicalDailyTimestamp(a.start_time.slice(0, 10));
if (Number.isNaN(measuredAt.getTime())) return [];
const out: MappedMeasurement[] = [];
const steps = a["active-steps"];
const steps = a.steps;
if (typeof steps === "number" && steps >= 0) {
out.push({
type: "ACTIVITY_STEPS",
@@ -586,7 +575,7 @@ export function mapActivity(a: PolarActivity): MappedMeasurement[] {
fieldTag: "steps",
});
}
const activeCalories = a["active-calories"];
const activeCalories = a.active_calories;
if (typeof activeCalories === "number" && activeCalories >= 0) {
out.push({
type: "ACTIVE_ENERGY_BURNED",
@@ -630,12 +619,13 @@ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] {
];
}
/** Map one Polar SpO2 test result: `blood_oxygen_percentage` →
* `OXYGEN_SATURATION` (percent, 0..100). */
/** Map one Polar SpO2 test result: `blood_oxygen_percent` →
* `OXYGEN_SATURATION` at the test's exact Unix timestamp. */
export function mapSpo2(r: PolarSpo2): MappedMeasurement[] {
const measuredAt = canonicalDailyTimestamp(r.date);
if (typeof r.test_time !== "number") return [];
const measuredAt = new Date(r.test_time * 1000);
if (Number.isNaN(measuredAt.getTime())) return [];
const pct = r.blood_oxygen_percentage;
const pct = r.blood_oxygen_percent;
if (typeof pct !== "number" || pct <= 0 || pct > 100) return [];
return [
{
+5 -5
View File
@@ -115,11 +115,11 @@ export async function syncUserPolar(userId: string): Promise<number> {
try {
const [recharges, sleeps, activities, cardioLoads, spo2Tests] =
await Promise.all([
fetchNightlyRecharges(conn.accessToken, conn.polarUserId),
fetchSleeps(conn.accessToken, conn.polarUserId),
fetchActivities(conn.accessToken, conn.polarUserId),
fetchCardioLoads(conn.accessToken, conn.polarUserId),
fetchSpo2(conn.accessToken, conn.polarUserId),
fetchNightlyRecharges(conn.accessToken),
fetchSleeps(conn.accessToken),
fetchActivities(conn.accessToken),
fetchCardioLoads(conn.accessToken),
fetchSpo2(conn.accessToken),
]);
for (const r of recharges) {
readings.push(...toUpsert(mapNightlyRecharge(r), "recharge"));