diff --git a/src/lib/google-health/__tests__/rollup-request.test.ts b/src/lib/google-health/__tests__/rollup-request.test.ts new file mode 100644 index 000000000..54e23c31b --- /dev/null +++ b/src/lib/google-health/__tests__/rollup-request.test.ts @@ -0,0 +1,290 @@ +/** + * Pins the dailyRollUp request contract against the documented v4 shape. + * + * The request body mirrors the official example (developers.google.com/health/ + * endpoints): both range bounds are explicit CivilDateTime objects + * (`{date:{year,month,day}, time:{hours,minutes,seconds,nanos}}`) and the + * `end` bound is the LAST covered civil day at 23:59:59 — NOT the next day's + * midnight. The range validator counts the civil days a range touches against + * the documented cap ("The maximum range for all other data types is 90 + * days"), so an exclusive next-day-midnight end makes a maximal 90-day chunk + * read as 91 days and 400s — the shape that broke the first live backfill + * chunk. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() })); +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, safeFetch: safeFetchMock }; +}); + +import { + GOOGLE_HEALTH_DATA_TYPES, + GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS, + GOOGLE_HEALTH_ROLLUP_RANGE_DAYS, + buildDailyRollUpBody, + extractGoogleApiErrorDetail, + fetchDailyRollUp, + type GoogleHealthRollupShape, +} from "../client"; +import { GoogleHealthApiError } from "../response-classifier"; + +function jsonResponse(status: number, body: unknown): Response { + return { + status, + ok: status >= 200 && status < 300, + json: async () => body, + } as unknown as Response; +} + +/** Inclusive civil-day count a captured request-body range touches. */ +function touchedDays(body: { + range: { + start: { date: { year: number; month: number; day: number } }; + end: { date: { year: number; month: number; day: number } }; + }; +}): number { + const s = body.range.start.date; + const e = body.range.end.date; + return ( + (Date.UTC(e.year, e.month - 1, e.day) - + Date.UTC(s.year, s.month - 1, s.day)) / + (24 * 60 * 60 * 1000) + + 1 + ); +} + +function capturedBodies(): Array<{ + range: { + start: { date: { year: number; month: number; day: number } }; + end: { date: { year: number; month: number; day: number } }; + }; +}> { + return safeFetchMock.mock.calls.map((c) => + JSON.parse((c[1] as { body: string }).body), + ); +} + +beforeEach(() => { + safeFetchMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-30T12:00:00Z")); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("buildDailyRollUpBody — documented request shape", () => { + it("matches the documented single-day example byte-for-byte", () => { + // Doc example: one civil day rolls up as [day 00:00:00, same day 23:59:59]. + expect( + buildDailyRollUpBody({ + start: { year: 2026, month: 2, day: 26 }, + end: { year: 2026, month: 2, day: 27 }, + }), + ).toEqual({ + range: { + start: { + date: { year: 2026, month: 2, day: 26 }, + time: { hours: 0, minutes: 0, seconds: 0, nanos: 0 }, + }, + end: { + date: { year: 2026, month: 2, day: 26 }, + time: { hours: 23, minutes: 59, seconds: 59, nanos: 0 }, + }, + }, + windowSizeDays: 1, + }); + }); + + it("keeps a maximal 90-day chunk inside the documented 90-day cap", () => { + const body = buildDailyRollUpBody({ + start: { year: 2026, month: 1, day: 1 }, + end: { year: 2026, month: 4, day: 1 }, // exclusive: 90 civil days + }) as Parameters[0]; + // End bound lands on the last covered day (Mar 31), not Apr 1 midnight. + expect(body.range.end.date).toEqual({ year: 2026, month: 3, day: 31 }); + expect(touchedDays(body)).toBe(GOOGLE_HEALTH_ROLLUP_RANGE_DAYS); + }); + + it("omits pageSize and carries pageToken only when paginating", () => { + const first = buildDailyRollUpBody({ + start: { year: 2026, month: 6, day: 1 }, + end: { year: 2026, month: 6, day: 8 }, + }); + expect(first).not.toHaveProperty("pageSize"); + expect(first).not.toHaveProperty("pageToken"); + const next = buildDailyRollUpBody( + { + start: { year: 2026, month: 6, day: 1 }, + end: { year: 2026, month: 6, day: 8 }, + }, + "tok", + ); + expect(next.pageToken).toBe("tok"); + }); +}); + +describe("fetchDailyRollUp — chunk walk + conservative fallback", () => { + it("walks 90-day chunks and reports the standard shape", async () => { + safeFetchMock.mockResolvedValue( + jsonResponse(200, { + rollupDataPoints: [{ steps: { countSum: "100" } }], + }), + ); + let shape: GoogleHealthRollupShape | undefined; + const points = await fetchDailyRollUp( + GOOGLE_HEALTH_DATA_TYPES.steps, + "token", + "fetchSteps", + { + start: new Date("2026-01-01T00:00:00Z"), + onShape: (s) => { + shape = s; + }, + }, + ); + expect(shape).toBe("days90"); + // 2026-01-01 → 2026-07-01 (exclusive tomorrow) = 181 days → 3 chunks. + expect(safeFetchMock).toHaveBeenCalledTimes(3); + expect(points).toHaveLength(3); + for (const body of capturedBodies()) { + expect(touchedDays(body)).toBeLessThanOrEqual( + GOOGLE_HEALTH_ROLLUP_RANGE_DAYS, + ); + } + }); + + it("re-walks at 14 days when the FIRST request is rejected with a 400", async () => { + safeFetchMock.mockResolvedValueOnce( + jsonResponse(400, { + error: { code: 400, status: "INVALID_ARGUMENT", message: "bad range" }, + }), + ); + safeFetchMock.mockResolvedValue( + jsonResponse(200, { rollupDataPoints: [] }), + ); + let shape: GoogleHealthRollupShape | undefined; + await fetchDailyRollUp( + GOOGLE_HEALTH_DATA_TYPES.steps, + "token", + "fetchSteps", + { + start: new Date("2026-06-01T00:00:00Z"), + onShape: (s) => { + shape = s; + }, + }, + ); + expect(shape).toBe("days14"); + // First body was a single 30-day chunk; the retry re-chunks at ≤14 days. + const bodies = capturedBodies(); + expect(bodies.length).toBeGreaterThan(1); + for (const body of bodies.slice(1)) { + expect(touchedDays(body)).toBeLessThanOrEqual( + GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS, + ); + } + }); + + it("propagates a 400 past the first request instead of re-walking", async () => { + safeFetchMock.mockResolvedValueOnce( + jsonResponse(200, { rollupDataPoints: [] }), + ); + safeFetchMock.mockResolvedValueOnce( + jsonResponse(400, { + error: { code: 400, status: "INVALID_ARGUMENT", message: "nope" }, + }), + ); + await expect( + fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", { + start: new Date("2026-01-01T00:00:00Z"), + }), + ).rejects.toBeInstanceOf(GoogleHealthApiError); + expect(safeFetchMock).toHaveBeenCalledTimes(2); + }); + + it("propagates a non-400 first-request failure without a fallback walk", async () => { + safeFetchMock.mockResolvedValueOnce(jsonResponse(500, {})); + await expect( + fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", { + start: new Date("2026-06-01T00:00:00Z"), + }), + ).rejects.toBeInstanceOf(GoogleHealthApiError); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("extractGoogleApiErrorDetail — AIP-193 field violations", () => { + it("keeps the plain STATUS: message shape without details", () => { + expect( + extractGoogleApiErrorDetail({ + error: { code: 400, status: "INVALID_ARGUMENT", message: "Invalid" }, + }), + ).toBe("INVALID_ARGUMENT: Invalid"); + }); + + it("appends fieldViolations as field: description fragments", () => { + expect( + extractGoogleApiErrorDetail({ + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Invalid argument in request.", + details: [ + { + "@type": "type.googleapis.com/google.rpc.BadRequest", + fieldViolations: [ + { field: "range", description: "Range too wide." }, + ], + }, + ], + }, + }), + ).toBe( + "INVALID_ARGUMENT: Invalid argument in request. [range: Range too wide.]", + ); + }); + + it("caps at three violations and tolerates a details-only envelope", () => { + const detail = extractGoogleApiErrorDetail({ + error: { + details: [ + { + fieldViolations: [ + { field: "a", description: "1" }, + { field: "b", description: "2" }, + { field: "c", description: "3" }, + { field: "d", description: "4" }, + ], + }, + ], + }, + }); + expect(detail).toBe("[a: 1; b: 2; c: 3]"); + }); + + it("redacts tokens and URL queries inside violation descriptions", () => { + const detail = extractGoogleApiErrorDetail({ + error: { + status: "INVALID_ARGUMENT", + details: [ + { + fieldViolations: [ + { + field: "range.start", + description: + "Bearer abc123 rejected at https://health.googleapis.com/v4/x?filter=secret", + }, + ], + }, + ], + }, + }); + expect(detail).not.toContain("abc123"); + expect(detail).not.toContain("filter=secret"); + }); +}); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index b409a4c43..73b63c45d 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -27,7 +27,7 @@ * a refresh token (and force one on every re-consent). */ import { createHash, randomBytes } from "node:crypto"; -import { getEvent } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; import { @@ -677,25 +677,63 @@ function redactApiErrorMessage(msg: string): string { } /** - * Extract the AIP-193 error envelope (`{"error":{code,message,status}}`) from a - * non-2xx Google Health body into a short redacted `STATUS: message` detail - * string, or undefined when the body carries no such envelope. This is what - * makes a field-grammar 400 (`INVALID_ARGUMENT: Invalid filter …`) diagnosable - * from operator logs. (The OAuth token endpoint uses the flat - * `{"error":"invalid_grant"}` shape instead — handled in `postToken`.) + * Collect the `google.rpc.BadRequest` field violations out of an AIP-193 + * `error.details[]` array as `field: description` fragments. Only the field + * PATH and the description sentence survive — both run through the message + * redactor and a per-fragment cap, and at most three violations are kept, so + * no request value can ride an error string into the logs. + */ +function extractFieldViolations(details: unknown): string[] { + if (!Array.isArray(details)) return []; + const out: string[] = []; + for (const d of details) { + if (!d || typeof d !== "object") continue; + const fv = (d as { fieldViolations?: unknown }).fieldViolations; + if (!Array.isArray(fv)) continue; + for (const v of fv) { + if (out.length >= 3) return out; + if (!v || typeof v !== "object") continue; + const o = v as { field?: unknown; description?: unknown }; + const field = + typeof o.field === "string" + ? redactApiErrorMessage(o.field).slice(0, 80) + : undefined; + const description = + typeof o.description === "string" + ? redactApiErrorMessage(o.description).slice(0, 120) + : undefined; + if (!field && !description) continue; + out.push([field, description].filter(Boolean).join(": ")); + } + } + return out; +} + +/** + * Extract the AIP-193 error envelope (`{"error":{code,message,status,details}}`) + * from a non-2xx Google Health body into a short redacted + * `STATUS: message [field: description]` detail string, or undefined when the + * body carries no such envelope. This is what makes a field-grammar 400 + * (`INVALID_ARGUMENT: Invalid filter …`) diagnosable from operator logs: the + * `details[]` BadRequest field violations name the exact offending request + * field. (The OAuth token endpoint uses the flat `{"error":"invalid_grant"}` + * shape instead — handled in `postToken`.) */ export function extractGoogleApiErrorDetail(json: unknown): string | undefined { if (!json || typeof json !== "object") return undefined; const e = (json as { error?: unknown }).error; if (!e || typeof e !== "object") return undefined; - const o = e as { status?: unknown; message?: unknown }; + const o = e as { status?: unknown; message?: unknown; details?: unknown }; const status = typeof o.status === "string" ? o.status : undefined; const message = typeof o.message === "string" ? redactApiErrorMessage(o.message) : undefined; - if (!status && !message) return undefined; - return [status, message].filter(Boolean).join(": "); + const violations = extractFieldViolations(o.details); + if (!status && !message && violations.length === 0) return undefined; + const head = [status, message].filter(Boolean).join(": "); + const tail = violations.length > 0 ? `[${violations.join("; ")}]` : ""; + return [head, tail].filter(Boolean).join(" ").slice(0, 500); } /** @@ -778,9 +816,25 @@ export async function fetchDataPoints( // ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ───────────────── -/** Max civil days one dailyRollUp request range may span (per the v4 docs). */ +/** + * Max civil days one dailyRollUp request range may span. Per the v4 reference + * (`users.dataTypes.dataPoints/dailyRollUp`, `range`): "The maximum range for + * `calories-in-heart-rate-zone`, `heart-rate`, `active-minutes` and + * `total-calories` is 14 days. The maximum range for all other data types is + * 90 days." The four types read here (steps / distance / active-energy-burned / + * floors) all sit in the 90-day class; the 14-day cap would only bind if + * `heart-rate` or `total-calories` ever moved onto the rollup path. + */ export const GOOGLE_HEALTH_ROLLUP_RANGE_DAYS = 90; +/** + * Conservative chunk span for the one-shot fallback walk: the tightest range + * cap the dailyRollUp docs state for ANY data type (the 14-day class above). + * Used only after the standard 90-day first request is rejected with a 400 — + * see `fetchDailyRollUp`. + */ +export const GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS = 14; + /** * Full-sync horizon for the rollup types, in civil days. The rollup read needs * an explicit range (unlike the unbounded list walk), so the backfill is @@ -872,15 +926,70 @@ interface RollupQuery { start?: Date; /** The user's IANA zone — the civil range is user-local. */ tz?: string; + /** + * Observes which request shape the walk settled on (`days90` standard vs + * `days14` fallback) — the structure probe surfaces it so a live account + * reports which shape Google actually accepted. + */ + onShape?: (shape: GoogleHealthRollupShape) => void; +} + +/** The dailyRollUp request shape a walk settled on. */ +export type GoogleHealthRollupShape = "days90" | "days14"; + +/** + * Build the documented dailyRollUp request body for one closed-open civil-day + * chunk (`end` exclusive). Doc-example parity (developers.google.com/health/ + * endpoints, dailyRollUp sample): both range bounds carry explicit + * `{date, time}` CivilDateTime objects, and the `end` bound is the LAST civil + * day INSIDE the chunk at 23:59:59 — NOT the next day's midnight. The range + * validator counts the civil days the range touches against the documented + * cap, so an exclusive next-day-midnight end makes a maximal chunk read one + * day too wide. `windowSizeDays: 1` is the documented daily-total window; + * `pageSize` is omitted (the documented default of 1440 already covers the + * ≤90 daily windows a chunk can produce); `pageToken` rides along on + * follow-up pages ("All other request fields need to be the same as in the + * initial request when the page token is specified"). + */ +export function buildDailyRollUpBody( + chunk: { start: GoogleHealthCivilDate; end: GoogleHealthCivilDate }, + pageToken?: string, +): Record { + const lastDay = utcMsToCivil(civilToUtcMs(chunk.end) - DAY_MS); + const body: Record = { + range: { + start: { + date: chunk.start, + time: { hours: 0, minutes: 0, seconds: 0, nanos: 0 }, + }, + end: { + date: lastDay, + time: { hours: 23, minutes: 59, seconds: 59, nanos: 0 }, + }, + }, + windowSizeDays: 1, + }; + if (pageToken) body.pageToken = pageToken; + return body; } /** * Read one cumulative data type's daily totals via `POST …/dataPoints:dailyRollUp` - * with `windowSizeDays: 1`. The civil range is closed-open and user-local, - * chunked at ≤90 days per request; without an incremental `start` the walk - * covers the pinned backfill horizon. A `nextPageToken` is honoured defensively - * (the response is documented without one, but the request accepts page - * tokens). + * with `windowSizeDays: 1`. The civil range is user-local, chunked at ≤90 days + * per request (the documented cap for these types); without an incremental + * `start` the walk covers the pinned backfill horizon. A `nextPageToken` is + * honoured defensively (the dailyRollUp response is documented without one, + * but the request accepts page tokens and the sibling `:rollUp` documents the + * response field). + * + * FALLBACK: if the very first request of a walk is rejected with a 400 + * (INVALID_ARGUMENT — a range/shape constraint, since the body already + * mirrors the documented example), the whole range is re-walked once with the + * most conservative documented span (14 days, the tightest cap the docs state + * for any type). The shape that succeeded is annotated as + * `googleHealth.rollup.shape` so live behaviour reports which constraint + * actually binds. Any other failure, or a 400 past the first request, + * propagates. */ export async function fetchDailyRollUp( dataType: GoogleHealthDataType, @@ -896,71 +1005,84 @@ export async function fetchDailyRollUp( const startCivil = civilDateInTz(from, query.tz); const endCivil = civilDateInTz(new Date(now.getTime() + DAY_MS), query.tz); - const points: GoogleHealthRollupPoint[] = []; - let chunkIndex = 0; - for (const chunk of chunkCivilRange(startCivil, endCivil)) { - let pageToken: string | null | undefined; - let pageCount = 0; - do { - const body: Record = { - range: { - start: { date: chunk.start }, - end: { date: chunk.end }, - }, - windowSizeDays: 1, - pageSize: 100, - }; - if (pageToken) body.pageToken = pageToken; + let requestCount = 0; - const reqStart = performance.now(); - const res = await safeFetch( - `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`, - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", + const walk = async (maxDays: number): Promise => { + const points: GoogleHealthRollupPoint[] = []; + let chunkIndex = 0; + for (const chunk of chunkCivilRange(startCivil, endCivil, maxDays)) { + let pageToken: string | null | undefined; + let pageCount = 0; + do { + const body = buildDailyRollUpBody(chunk, pageToken ?? undefined); + + requestCount += 1; + const reqStart = performance.now(); + const res = await safeFetch( + `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }, - ); - const json = (await res - .json() - .catch(() => null)) as GoogleHealthRollupPage | null; - const verdict = classifyGoogleHealthResponse(res.status); - const apiErrorDetail = - verdict.classification === "success" - ? undefined - : extractGoogleApiErrorDetail(json); - getEvent()?.addExternalCall({ - service: "google-health", - method: `${verb}(chunk=${chunkIndex},page=${pageCount})`, - duration_ms: Math.round(performance.now() - reqStart), - status: res.status, - error: + ); + const json = (await res + .json() + .catch(() => null)) as GoogleHealthRollupPage | null; + const verdict = classifyGoogleHealthResponse(res.status); + const apiErrorDetail = verdict.classification === "success" ? undefined - : apiErrorDetail - ? `${verdict.reason} ${apiErrorDetail}` - : verdict.reason, - }); - if (verdict.classification !== "success") { - throw new GoogleHealthApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError: apiErrorDetail, + : extractGoogleApiErrorDetail(json); + getEvent()?.addExternalCall({ + service: "google-health", + method: `${verb}(chunk=${chunkIndex},page=${pageCount})`, + duration_ms: Math.round(performance.now() - reqStart), + status: res.status, + error: + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, }); - } + if (verdict.classification !== "success") { + throw new GoogleHealthApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + upstreamError: apiErrorDetail, + }); + } - for (const p of json?.rollupDataPoints ?? []) points.push(p); - pageToken = json?.nextPageToken ?? null; - pageCount += 1; - } while (pageToken && pageCount < 100); - chunkIndex += 1; + for (const p of json?.rollupDataPoints ?? []) points.push(p); + pageToken = json?.nextPageToken ?? null; + pageCount += 1; + } while (pageToken && pageCount < 100); + chunkIndex += 1; + } + return points; + }; + + let shape: GoogleHealthRollupShape = "days90"; + let points: GoogleHealthRollupPoint[]; + try { + points = await walk(GOOGLE_HEALTH_ROLLUP_RANGE_DAYS); + } catch (err) { + const firstRequestRejected = + requestCount === 1 && + err instanceof GoogleHealthApiError && + err.httpStatus === 400; + if (!firstRequestRejected) throw err; + shape = "days14"; + points = await walk(GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS); } - + annotate({ meta: { "googleHealth.rollup.shape": shape } }); + query.onShape?.(shape); return points; } diff --git a/src/lib/google-health/mapping.md b/src/lib/google-health/mapping.md index 694c9e022..276a9b14e 100644 --- a/src/lib/google-health/mapping.md +++ b/src/lib/google-health/mapping.md @@ -44,9 +44,14 @@ Two read methods are in use — NOT every type supports `dataPoints.list`: - **`dataPoints:dailyRollUp`** (POST, `windowSizeDays: 1`) — the cumulative activity totals (steps, distance, active-energy-burned, floors). Their list surface returns minute-grain observation buckets (≈1440/day), NOT daily - totals — and **floors has no list method at all**. The request range is - CivilDateTime, closed-open, user-local, max **90 days** per request; the - client chunks longer ranges and pins the full-sync horizon + totals — and **floors has no list method at all**. The request range is a + pair of CivilDateTime bounds (`{date:{year,month,day}, time:{hours,minutes, +seconds,nanos}}`), user-local; per the documented example, the `end` bound + is the LAST covered civil day at 23:59:59 — not the next day's midnight. + Max **90 days** per request for these types (14 days only for + heart-rate / total-calories / active-minutes / calories-in-heart-rate-zone); + the client chunks longer ranges (`buildDailyRollUpBody`), falls back once to + 14-day chunks if the first request 400s, and pins the full-sync horizon (`GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS`). Legal incremental `filter` fields are per-shape — anything else is an HTTP 400