diff --git a/src/lib/polar/__tests__/client-exercises.test.ts b/src/lib/polar/__tests__/client-exercises.test.ts index cf41fa331..60e1ee6b7 100644 --- a/src/lib/polar/__tests__/client-exercises.test.ts +++ b/src/lib/polar/__tests__/client-exercises.test.ts @@ -1,4 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { annotateMock } = vi.hoisted(() => ({ annotateMock: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ + annotate: annotateMock, + getEvent: () => null, +})); import { combinePolarStart, @@ -25,11 +31,21 @@ function installFetchMock(pages: Array<{ status: number; body?: unknown }>) { return fetchMock; } +beforeEach(() => { + annotateMock.mockClear(); +}); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); +function driftAnnotations() { + return annotateMock.mock.calls + .map((c) => c[0]) + .filter((f) => f?.action?.name === "polar.envelope.drift"); +} + describe("fetchExercises — request path", () => { // #568 regression guard: the exercises collection GET carries NO userId in // the path (the user is identified by the Bearer token), exactly the shape @@ -118,6 +134,44 @@ describe("fetchExercises — body shape (assumption #3)", () => { }); }); +describe("fetchExercises — envelope drift logging (E-77)", () => { + it("logs a drift breadcrumb and degrades to [] on an object body with no recognised list key", async () => { + installFetchMock([{ status: 200, body: { nope: [{ id: "a" }] } }]); + expect(await fetchExercises("tok")).toEqual([]); + const drift = driftAnnotations(); + expect(drift).toHaveLength(1); + expect(drift[0].action.details).toMatchObject({ + verb: "fetchExercises", + httpStatus: 200, + keys: ["nope"], + }); + }); + + it("does NOT drift on a bare array", async () => { + installFetchMock([{ status: 200, body: [{ id: "a" }] }]); + expect(await fetchExercises("tok")).toHaveLength(1); + expect(driftAnnotations()).toHaveLength(0); + }); + + it("does NOT drift on a recognised envelope key (`data`)", async () => { + installFetchMock([{ status: 200, body: { data: [{ id: "a" }] } }]); + expect(await fetchExercises("tok")).toHaveLength(1); + expect(driftAnnotations()).toHaveLength(0); + }); + + it("does NOT drift on an empty array under a recognised key", async () => { + installFetchMock([{ status: 200, body: { exercises: [] } }]); + expect(await fetchExercises("tok")).toEqual([]); + expect(driftAnnotations()).toHaveLength(0); + }); + + it("does NOT drift on a 204 empty window", async () => { + installFetchMock([{ status: 204 }]); + expect(await fetchExercises("tok")).toEqual([]); + expect(driftAnnotations()).toHaveLength(0); + }); +}); + describe("parseIsoDuration", () => { it("parses hours/minutes/seconds", () => { expect(parseIsoDuration("PT2H44M30S")).toBe(2 * 3600 + 44 * 60 + 30); diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 834ffb0ca..a92276f8f 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const { annotateMock } = vi.hoisted(() => ({ annotateMock: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ + annotate: annotateMock, + getEvent: () => null, +})); + import { POLAR_OAUTH_SCOPE, exchangeCode, @@ -43,12 +49,19 @@ function installFetchMock(pages: Array<{ status: number; body?: unknown }>) { } beforeEach(() => { + annotateMock.mockClear(); process.env.POLAR_CLIENT_ID = "env-cid"; process.env.POLAR_CLIENT_SECRET = "env-secret"; process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; delete process.env.POLAR_REDIRECT_URI; }); +function driftAnnotations() { + return annotateMock.mock.calls + .map((c) => c[0]) + .filter((f) => f?.action?.name === "polar.envelope.drift"); +} + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -209,6 +222,55 @@ describe("fetchNightlyRecharges", () => { }); }); +describe("fetchCollection — envelope drift logging", () => { + it("logs a drift breadcrumb (verb + top-level keys) and degrades to [] on an unexpected wrapped shape", async () => { + installFetchMock([ + { status: 200, body: { unexpected: [{ date: "2026-06-10" }] } }, + ]); + expect(await fetchNightlyRecharges("tok")).toEqual([]); + const drift = driftAnnotations(); + expect(drift).toHaveLength(1); + expect(drift[0].action.details).toMatchObject({ + verb: "fetchNightlyRecharges", + httpStatus: 200, + keys: ["unexpected"], + }); + }); + + it("does NOT drift on the correctly wrapped envelope", async () => { + installFetchMock([ + { status: 200, body: { recharges: [{ date: "2026-06-10" }] } }, + ]); + expect(await fetchNightlyRecharges("tok")).toHaveLength(1); + expect(driftAnnotations()).toHaveLength(0); + }); + + it("does NOT drift on a bare array for a bare-array endpoint", async () => { + installFetchMock([ + { status: 200, body: [{ date: "2026-06-10", cardio_load: 12 }] }, + ]); + expect(await fetchCardioLoads("tok")).toHaveLength(1); + expect(driftAnnotations()).toHaveLength(0); + }); + + it("drifts when a bare-array endpoint returns a non-array object", async () => { + installFetchMock([{ status: 200, body: { rows: [{ cardio_load: 1 }] } }]); + expect(await fetchCardioLoads("tok")).toEqual([]); + const drift = driftAnnotations(); + expect(drift).toHaveLength(1); + expect(drift[0].action.details).toMatchObject({ + verb: "fetchCardioLoads", + keys: ["rows"], + }); + }); + + it("does NOT drift on a 204 empty window", async () => { + installFetchMock([{ status: 204 }]); + expect(await fetchSleeps("tok")).toEqual([]); + expect(driftAnnotations()).toHaveLength(0); + }); +}); + describe("mapNightlyRecharge", () => { it("maps the recovery band to a 0-100 RECOVERY_SCORE", () => { const rec: PolarNightlyRecharge = { diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index de3a4dc94..4a6092a06 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -1,6 +1,9 @@ /** * v1.17.0 (F4) — Polar AccessLink v3 client for OAuth + data fetching. * Docs: https://www.polar.com/accesslink-api/ (re-verify at build). + * Envelope shapes + windows below verified against the live AccessLink swagger + * (`accesslink-api/swagger.yaml`) on 2026-07-24; a body that no longer matches + * emits a `polar.envelope.drift` breadcrumb rather than silently reading empty. * * Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled * fetch over `safeFetch` (no SDK), an OAuth handshake, typed collection @@ -20,7 +23,7 @@ * reads work; a 409 from registration means "already registered" and is * treated as success. */ -import { getEvent } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { safeFetch } from "@/lib/safe-fetch"; import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; @@ -250,6 +253,30 @@ interface CollectionResult { records: T[]; } +/** Top-level key NAMES of a drifted response envelope, capped at 10. NAMES + * only, never values — no health data can ride the drift annotation. */ +function topLevelKeys(body: unknown): string[] { + if (!body || typeof body !== "object" || Array.isArray(body)) return []; + return Object.keys(body as Record).slice(0, 10); +} + +/** Emit the fixed-shape envelope-drift breadcrumb, then let the caller degrade + * to `[]`. Pinned `{verb, httpStatus, keys}` shape so the wide-event dashboard + * stays stable; a Polar-side envelope rename shows up here instead of silently + * reading as "no data". */ +function annotateEnvelopeDrift( + verb: string, + httpStatus: number, + body: unknown, +): void { + annotate({ + action: { + name: "polar.envelope.drift", + details: { verb, httpStatus, keys: topLevelKeys(body) }, + }, + }); +} + async function fetchCollection( path: string, accessToken: string, @@ -284,13 +311,30 @@ async function fetchCollection( if (res.status === 204) return []; const json = (await res.json().catch(() => null)) as CollectionResult | Record | T[] | null; - if (!json) return []; + // A 200 with a null/unparseable body where the contract documents 204 for an + // empty window: log the drift, then degrade to [] (behavior unchanged). + if (json === null) { + annotateEnvelopeDrift(verb, res.status, null); + return []; + } if (Array.isArray(json)) return json; - if (!recordsKey) return []; + if (!recordsKey) { + // A bare array is the verified shape for this endpoint (activities / + // cardio-load / spo2) but the body is a non-array object — an envelope + // rename would read as "no data". Log, then degrade. + annotateEnvelopeDrift(verb, res.status, json); + return []; + } const list = (json as Record)[recordsKey]; - return Array.isArray(list) ? (list as T[]) : []; + if (Array.isArray(list)) return list as T[]; + // The expected records key (`recharges` / `nights`) is missing or not an + // array — the same silent-"no data" drift class. Log, then degrade. + annotateEnvelopeDrift(verb, res.status, json); + return []; } +/** Nightly Recharge collection. Verified envelope: wrapped `{recharges: [...]}`. + * Window: the last 28 days, server-fixed (no request params, no pagination). */ export function fetchNightlyRecharges( accessToken: string, ): Promise { @@ -302,6 +346,8 @@ export function fetchNightlyRecharges( ); } +/** Sleep collection. Verified envelope: wrapped `{nights: [...]}`. Window: the + * last 28 days, server-fixed (no request params, no pagination). */ export function fetchSleeps(accessToken: string): Promise { return fetchCollection( "/v3/users/sleep", @@ -311,6 +357,11 @@ export function fetchSleeps(accessToken: string): Promise { ); } +/** Daily-activity collection. Verified envelope: a BARE array (no wrapper key). + * Window: defaults to the last 28 days; optional `from`/`to` (max 28-day range, + * `from` ≤ 365 days back) are deliberately not sent — the default equals the + * in-window maximum, so pinning them buys no coverage and adds a request-surface + * failure mode. No pagination. */ export function fetchActivities(accessToken: string): Promise { return fetchCollection( "/v3/users/activities", @@ -319,8 +370,10 @@ export function fetchActivities(accessToken: string): Promise { ); } -/** Training Load Pro collection — `cardio_load` strain figures for the recent - * window. */ +/** Training Load Pro collection — `cardio_load` strain figures. Verified + * envelope: a BARE array (no wrapper key). Window: the last 28 days on the bare + * list (ranged `/date` and `/period/days/{1..180}` variants exist for deeper + * history but are backfill machinery, not sent here). No pagination. */ export function fetchCardioLoads( accessToken: string, ): Promise { @@ -331,7 +384,9 @@ export function fetchCardioLoads( ); } -/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. */ +/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. Verified + * envelope: a BARE array (no wrapper key). Window: defaults to the last 28 days + * (optional `from`/`to`, max 28-day range, not sent). No pagination. */ export function fetchSpo2(accessToken: string): Promise { return fetchCollection( "/v3/users/biosensing/spo2", @@ -697,13 +752,13 @@ export interface PolarExercise { } /** - * Candidate envelope keys a paged `/v3/exercises` response might nest the list - * under. Polar's pagination shape on this endpoint is not verified; the fetcher - * tolerates a bare array OR any of these envelope shapes so a paged response - * degrades to "the rows on this page" rather than an empty result. The 30-day - * window is small enough that a single page covers a normal user; if Polar - * paginates, the hourly poll re-reads the whole window every tick so nothing is - * lost between pages. + * Candidate envelope keys the `/v3/exercises` list might nest under. The + * verified shape (AccessLink swagger, 2026-07-24) is a BARE array covering the + * last 30 days OF UPLOADS, with NO pagination token or page parameter — so the + * bare-array branch is the live path. This tolerant key list is kept purely as + * defense against a future envelope rename; when it engages (an object body + * matching none of these keys), the fetch site logs a `polar.envelope.drift` + * breadcrumb rather than silently reading empty. */ const EXERCISE_LIST_KEYS = ["exercises", "data", "items", "records"] as const; @@ -746,6 +801,21 @@ export async function fetchExercises( // 204 No Content — a window with no uploads. Polar returns an empty body. if (res.status === 204) return []; const json = (await res.json().catch(() => null)) as unknown; + // Drift: a non-null object body carrying no recognised list array (none of + // EXERCISE_LIST_KEYS holds an array) would silently read as "no uploads". Log + // at the fetch site — `normaliseExerciseList` stays a pure function — then + // degrade to its []. A correctly-shaped `{exercises: []}` finds an (empty) + // array under a known key and does NOT drift. + if ( + json !== null && + typeof json === "object" && + !Array.isArray(json) && + !EXERCISE_LIST_KEYS.some((k) => + Array.isArray((json as Record)[k]), + ) + ) { + annotateEnvelopeDrift("fetchExercises", res.status, json); + } return normaliseExerciseList(json); }