diff --git a/src/lib/google-health/__tests__/rollup-request.test.ts b/src/lib/google-health/__tests__/rollup-request.test.ts index bed00a773..770010be4 100644 --- a/src/lib/google-health/__tests__/rollup-request.test.ts +++ b/src/lib/google-health/__tests__/rollup-request.test.ts @@ -26,6 +26,7 @@ import { buildDailyRollUpBody, extractGoogleApiErrorDetail, fetchDailyRollUp, + runWithGoogleHealthClientOutcome, type GoogleHealthRollupShape, } from "../client"; import { GoogleHealthApiError } from "../response-classifier"; @@ -219,6 +220,82 @@ describe("fetchDailyRollUp — chunk walk + conservative fallback", () => { }); }); +describe("fetchDailyRollUp — pages within a chunk (walkPages)", () => { + const oneChunk = { start: new Date("2026-06-20T00:00:00Z") }; + + it("follows nextPageToken to the end of a chunk", async () => { + safeFetchMock + .mockResolvedValueOnce( + jsonResponse(200, { + rollupDataPoints: [{ steps: { countSum: "1" } }], + nextPageToken: "p2", + }), + ) + .mockResolvedValueOnce( + jsonResponse(200, { + rollupDataPoints: [{ steps: { countSum: "2" } }], + nextPageToken: "", + }), + ); + const { result, outcome } = await runWithGoogleHealthClientOutcome(() => + fetchDailyRollUp( + GOOGLE_HEALTH_DATA_TYPES.steps, + "token", + "fetchSteps", + oneChunk, + ), + ); + expect(result).toHaveLength(2); + expect(safeFetchMock).toHaveBeenCalledTimes(2); + expect( + JSON.parse((safeFetchMock.mock.calls[1]![1] as { body: string }).body) + .pageToken, + ).toBe("p2"); + expect(outcome).toMatchObject({ pages: 2, truncated: false }); + }); + + it("stops a chunk whose page token comes back again and reports it truncated", async () => { + safeFetchMock.mockResolvedValue( + jsonResponse(200, { + rollupDataPoints: [{ steps: { countSum: "1" } }], + nextPageToken: "again", + }), + ); + const { result, outcome } = await runWithGoogleHealthClientOutcome(() => + fetchDailyRollUp( + GOOGLE_HEALTH_DATA_TYPES.steps, + "token", + "fetchSteps", + oneChunk, + ), + ); + // The first page hands out "again", the second hands it out again. + expect(safeFetchMock).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + expect(outcome).toMatchObject({ + truncated: true, + reasonCode: "existing_page_limit", + }); + }); + + it("stops a chunk at its page ceiling and reports it truncated", async () => { + let n = 0; + safeFetchMock.mockImplementation(async () => + jsonResponse(200, { rollupDataPoints: [], nextPageToken: `p${++n}` }), + ); + const { outcome } = await runWithGoogleHealthClientOutcome(() => + fetchDailyRollUp( + GOOGLE_HEALTH_DATA_TYPES.steps, + "token", + "fetchSteps", + oneChunk, + ), + ); + expect(safeFetchMock).toHaveBeenCalledTimes(100); + expect(outcome).toMatchObject({ pages: 100, truncated: true }); + }); +}); + describe("extractGoogleApiErrorDetail — AIP-193 field violations", () => { it("keeps the plain STATUS: message shape without details", () => { expect( diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index fcf70ecd5..d12006fc1 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -55,6 +55,7 @@ import { googleHealthWalkProgress, noteGoogleHealthWalkPage, } from "./backfill-bounds"; +import { walkPages } from "@/lib/integrations/page-walk"; export * from "./mappers"; @@ -1605,6 +1606,13 @@ export const GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS = 14; */ export const GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS = 5 * 365; +/** + * Page ceiling for one dailyRollUp chunk. A chunk of at most 90 daily windows + * fits on one page of the documented default size, so a chunk that pages at + * all is unusual and one that reaches this is a runaway token. + */ +const GOOGLE_HEALTH_ROLLUP_MAX_PAGES = 100; + /** A civil calendar date (1-based month), the dailyRollUp range unit. */ export interface GoogleHealthCivilDate { year: number; @@ -1778,56 +1786,61 @@ export async function fetchDailyRollUp( truncated = true; break; } - let pageToken: string | null | undefined; - let pageCount = 0; - do { - const body = buildDailyRollUpBody(chunk, pageToken ?? undefined); + const index = chunkIndex; + const chunkWalk = await walkPages( + `google_health.rollup.${dataType.path}`, + GOOGLE_HEALTH_ROLLUP_MAX_PAGES, + async (pageToken, pageCount) => { + const body = buildDailyRollUpBody(chunk, pageToken); - requestCount += 1; - const { json, verdict, apiErrorDetail } = - await googleHealthDataRequest( - accessToken, - `${verb}(chunk=${chunkIndex},page=${pageCount})`, - (token) => - safeFetch( - `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + requestCount += 1; + const { json, verdict, apiErrorDetail } = + await googleHealthDataRequest( + accessToken, + `${verb}(chunk=${index},page=${pageCount})`, + (token) => + safeFetch( + `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }, - ), - ); - if (verdict.classification !== "success") { - throw new GoogleHealthApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError: apiErrorDetail, - }); - } + ), + ); + if (verdict.classification !== "success") { + throw new GoogleHealthApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + upstreamError: apiErrorDetail, + }); + } - // Surface the raw envelope key names of the very first page (names - // only, never values) — see `RollupQuery.onEnvelopeKeys`. - if ( - chunkIndex === 0 && - pageCount === 0 && - json && - typeof json === "object" - ) { - query.onEnvelopeKeys?.(Object.keys(json)); - } - for (const p of json?.rollupDataPoints ?? []) points.push(p); - pageToken = json?.nextPageToken ?? null; - pageCount += 1; - noteSlicePage(); - } while (pageToken && pageCount < 100); - totalPages += pageCount; - truncated ||= Boolean(pageToken); + // Surface the raw envelope key names of the very first page (names + // only, never values) — see `RollupQuery.onEnvelopeKeys`. + if ( + index === 0 && + pageCount === 0 && + json && + typeof json === "object" + ) { + query.onEnvelopeKeys?.(Object.keys(json)); + } + noteSlicePage(); + return { + items: json?.rollupDataPoints ?? [], + next: json?.nextPageToken || null, + }; + }, + ); + for (const p of chunkWalk.items) points.push(p); + totalPages += chunkWalk.pages; + truncated ||= chunkWalk.truncated; chunkIndex += 1; } notePagination(totalPages, points.length, truncated);