diff --git a/docs/integrations/withings.md b/docs/integrations/withings.md index 58253d76d..99ddc858a 100644 --- a/docs/integrations/withings.md +++ b/docs/integrations/withings.md @@ -25,6 +25,56 @@ Activity data (steps, active energy, distance, floors climbed) lands via the `user.activity` OAuth scope shipped in v1.4.25. Sleep stages arrive via the sleep series endpoint. +## Withings API plans (October 2026) + +On 12 October 2026 Withings split its public API into plans +([plans page](https://static.withings.com/content/api/plans/plans.html)). +The developer app a self-hoster registers for HealthLog sits on the free +**Start for Free** plan, which covers the core biomarkers: activity, sleep, +body and heart. The FDA/CE-cleared biomarkers and a few body-composition +extras now need the paid **Advanced Biomarkers** plan. + +A request that names anything outside the plan is refused as a whole with +status 277 ("One of the data type requested in this API request is not +available in your API plan"). Before v1.42.3 HealthLog asked for every type +in one request, so from that date no Withings reading imported at all, and +the connection showed "Reauthorisation required" although reconnecting could +not help. + +**Still synced on the free plan:** weight, fat mass and fat-free mass, muscle +mass, bone mass, body water, blood pressure, heart rate, a manual SpO2 +reading, body temperature, steps, distance and calories, and the sleep +stages (duration, deep, light, REM, awake, wake-ups). + +**Needs the paid Advanced Biomarkers plan** (per Withings' +[available health data](https://developer.withings.com/developer-guide/v3/data-api/all-available-health-data/) +table): pulse wave velocity (meastype 91), vascular age (155), visceral fat +(170), VO2 max (123), skin temperature (73), ECG recordings and AFib +verdicts, and the sleep score, overnight HRV and overnight breathing rate +from the sleep summary. + +**What HealthLog does about it, automatically:** + +- When Withings refuses a request with 277, HealthLog asks again without + the types Withings lists as advanced. If that is still refused, it + splits the remaining types until it has found the ones the plan + refuses, within a fixed number of requests. Everything the plan allows + imports as before. +- The refused types are remembered per connection, so later syncs skip + them without a refused request. Once a week HealthLog asks for the full + set again, so a plan upgrade is picked up on its own. +- ECG recordings are skipped quietly while the plan refuses them. +- A plan refusal is never counted as a sync failure and never asks you to + reconnect. Connections that an earlier version parked over it are + released on the next hourly sync, and that sync imports everything since + the last successful one. +- Settings → Integrations → Withings shows a short note naming the + readings your plan withholds, with a link to the Withings plans page. + +Nothing needs to be configured. If you move your developer app to the +Advanced Biomarkers plan, the extra readings start arriving within a week, +or right away after reconnecting Withings once. + ## 1. Register a Withings developer app 1. Sign in at and **create a new @@ -167,6 +217,11 @@ fixes the mismatch by updating the URL Withings holds. ### Sync is stuck on "Reauthorisation required" +If this started on or after 12 October 2026 and your version is older than +v1.42.3, the cause is the API plan change, not your grant: see +[Withings API plans (October 2026)](#withings-api-plans-october-2026). +Updating is the fix; reconnecting is not needed. + Withings revokes refresh tokens on password change, app un-authorise, or after extended inactivity. The `WithingsConnection` row flips to `error_reauth` status and the scheduled sync no-ops until the user diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 1c0dde365..25b08cc78 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -52,7 +52,12 @@ const ouraAvailable = vi.fn(async () => false); vi.mock("@/lib/polar/credentials", () => ({ getPolarClientCredentials: () => polarAvailable(), })); -vi.mock("@/lib/oura/credentials", () => ({ +vi.mock("@/lib/oura/credentials", async () => ({ + readOuraScopeDenied: ( + await vi.importActual( + "@/lib/oura/credentials", + ) + ).readOuraScopeDenied, getOuraClientCredentials: () => ouraAvailable(), })); @@ -387,3 +392,55 @@ describe("/api/integrations/status — a provider with no ledger row", () => { expect(whoop.syncHealth?.verdict).toBe("disconnected"); }); }); + +describe("/api/integrations/status — what a provider withholds by contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("names the readings the Withings plan withholds and the Oura collections the grant lacks", async () => { + ( + prisma.withingsConnection.findUnique as ReturnType + ).mockResolvedValueOnce({ + tokenExpiresAt: new Date(Date.now() + 3_600_000), + lastSyncedAt: null, + createdAt: new Date(), + scope: "user.metrics,user.activity", + apiPlanState: { + version: 1, + measures: { + unavailable: [155, 54], + checkedAt: new Date().toISOString(), + }, + ecg: { unavailable: ["heart"], checkedAt: new Date().toISOString() }, + }, + }); + userFind.mockResolvedValue({ + ouraAccessTokenEncrypted: "tok", + ouraScopeDenied: ["resilience", "spo2", "not_a_collection"], + }); + + const entries = (await fetchEntries()) as Array< + Entry & { planUnavailable?: string[]; scopeReconnect?: string[] } + >; + const withings = entries.find((e) => e.integration === "withings")!; + const oura = entries.find((e) => e.integration === "oura")!; + + expect(withings.planUnavailable).toEqual(["spo2", "vascularAge", "ecg"]); + expect(oura.scopeReconnect).toEqual(["spo2", "resilience"]); + }); + + it("publishes empty lists when nothing is withheld", async () => { + userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" }); + + const entries = (await fetchEntries()) as Array< + Entry & { planUnavailable?: string[]; scopeReconnect?: string[] } + >; + expect( + entries.find((e) => e.integration === "withings")!.planUnavailable, + ).toEqual([]); + expect( + entries.find((e) => e.integration === "oura")!.scopeReconnect, + ).toEqual([]); + }); +}); diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index 20b99c393..12462e77d 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -232,3 +232,43 @@ describe("OAuthProviderCard — sync-now action", () => { ).not.toContain('data-testid="polar-sync"'); }); }); + +describe("OAuthProviderCard — Oura scope note", () => { + const scopeGap = { + connected: true, + configured: true, + available: true, + state: "connected" as const, + lastSuccessAt: "2026-10-14T00:00:00.000Z", + lastError: null, + syncHealth: { verdict: "fresh" as const, since: null }, + scopeReconnect: ["spo2" as const, "vo2max" as const], + }; + + function renderOura(viewModel: ViewModel) { + return renderToStaticMarkup( + + + , + ); + } + + it("asks for one reconnect beside a healthy pill, not an error", () => { + const html = renderOura(scopeGap); + expect(html).toContain('data-testid="oura-scope-note"'); + expect(html).toContain("Reconnect Oura once to allow SpO2 and VO2 max."); + expect(html).not.toContain('data-testid="oura-error"'); + }); + + it("stays away from the other OAuth providers", () => { + expect(render({ viewModel: scopeGap })).not.toContain("scope-note"); + }); +}); diff --git a/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx b/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx new file mode 100644 index 000000000..6a109ab19 --- /dev/null +++ b/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx @@ -0,0 +1,65 @@ +/** + * The two "withheld by contract" notes: the Withings card names what the + * account's API plan refuses, the Oura card asks for one reconnect when the + * grant lacks a scope. Both render nothing when nothing is withheld, and both + * render the server-resolved list rather than deriving one. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { WithingsPlanNote } from "../withings-plan-note"; +import { OuraScopeNote } from "../oura-scope-note"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("lists the withheld readings and links the Withings plans page", () => { + const html = render( + , + ); + expect(html).toContain('data-testid="withings-plan-note"'); + expect(html).toContain("SpO2, vascular age, and ECG and AFib"); + expect(html).toContain("Weight, blood pressure, body composition"); + expect(html).toContain( + "https://static.withings.com/content/api/plans/plans.html", + ); + }); + + it("speaks the reader's language", () => { + const html = render(, "de"); + expect(html).toContain("Gefäßalter"); + expect(html).toContain("kostenpflichtigen API-Plan"); + }); + + it("renders nothing when the plan withholds nothing", () => { + expect(render()).toBe(""); + expect(render()).toBe(""); + }); +}); + +describe("", () => { + it("asks for one reconnect naming the collections the grant lacks", () => { + const html = render( + , + ); + expect(html).toContain('data-testid="oura-scope-note"'); + expect(html).toContain( + "Reconnect Oura once to allow SpO2, VO2 max, cardiovascular age, and resilience.", + ); + expect(html).toContain('data-testid="oura-scope-reconnect"'); + }); + + it("renders nothing when the grant covers everything", () => { + expect( + render(), + ).toBe(""); + }); +}); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 7079809ad..4f819d2e1 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -898,3 +898,84 @@ describe("resolveOuraLookbackDays", () => { expect(await resolveOuraLookbackDays("u1")).toBe(21); }); }); + +describe("syncUserOura — a grant without the spo2 / heart_health scopes", () => { + const SCOPED = ["spo2", "vo2max", "cardio_age", "resilience"]; + + function denyScopedCollections() { + fetchSpo2Mock.mockRejectedValue(err401()); + fetchVo2MaxMock.mockRejectedValue(err401()); + fetchCardioAgeMock.mockRejectedValue(err401()); + fetchResilienceMock.mockRejectedValue(err401()); + fetchReadinessMock.mockResolvedValue([ + { id: "x", day: "2026-06-10", score: 70 }, + ]); + } + + it("records the scope gap instead of a failure, and never toward the reconnect count", async () => { + getConnMock.mockResolvedValue(CONN); + denyScopedCollections(); + refreshMock.mockResolvedValue({ + access_token: "newAcc", + refresh_token: "newRef", + expires_in: 86400, + }); + + const result = await syncUserOura("u1"); + + expect(result).toEqual({ imported: 1, failed: false }); + expect(recordFailureMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura"); + expect(scopeDeniedMock).toHaveBeenCalledWith("u1", SCOPED); + }); + + it("spends no token refresh on a scope gap it already knows", async () => { + getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED }); + denyScopedCollections(); + + const result = await syncUserOura("u1"); + + expect(refreshMock).not.toHaveBeenCalled(); + expect(result.failed).toBe(false); + expect(recordFailureMock).not.toHaveBeenCalled(); + expect(scopeDeniedMock).toHaveBeenCalledWith("u1", SCOPED); + }); + + it("clears the note once the grant covers every collection", async () => { + getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED }); + + await syncUserOura("u1"); + + expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []); + expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura"); + }); + + it("still treats a 401 on every collection as a dead token", async () => { + getConnMock.mockResolvedValue(CONN); + for (const m of [ + fetchReadinessMock, + fetchSleepMock, + fetchActivityMock, + fetchDailySleepMock, + fetchSpo2Mock, + fetchVo2MaxMock, + fetchCardioAgeMock, + fetchResilienceMock, + ]) { + m.mockRejectedValue(err401()); + } + refreshMock.mockResolvedValue({ + access_token: "newAcc", + refresh_token: "newRef", + expires_in: 86400, + }); + + const result = await syncUserOura("u1"); + + expect(result.failed).toBe(true); + expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ integration: "oura", kind: "reauth_required" }), + ); + }); +}); diff --git a/src/lib/withings/__tests__/sync-ecg.test.ts b/src/lib/withings/__tests__/sync-ecg.test.ts index c916e0a15..dc8cae0b7 100644 --- a/src/lib/withings/__tests__/sync-ecg.test.ts +++ b/src/lib/withings/__tests__/sync-ecg.test.ts @@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => { withingsConnection: { findUnique: vi.fn(), }, + $executeRaw: vi.fn(), $transaction: vi.fn( async (fn: (tx: unknown) => unknown) => await fn({ ecgRecording }), ), @@ -466,3 +467,82 @@ describe("syncUserEcg", () => { }); }); }); + +describe("syncUserEcg — Withings API plan (status 277)", () => { + function install277() { + const fetchMock = vi.fn(async () => ({ + status: 200, + json: async () => ({ + status: 277, + error: + "One of the data type requested in this API request is not available in your API plan", + }), + })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + /** The leg the plan-state write merged in, read off the raw SQL values. */ + function persistedLeg(): { leg: unknown; value: unknown } | null { + const call = vi.mocked(prisma.$executeRaw).mock.calls[0]; + if (!call) return null; + const values = call.slice(1); + return { leg: values[0], value: JSON.parse(String(values[1])) }; + } + + it("skips quietly on a 277: no failure, no throw, the refusal remembered", async () => { + install277(); + const imported = await syncUserEcg("user-1"); + + expect(imported).toBe(0); + expect(recordSyncFailure).not.toHaveBeenCalled(); + expect(recordWithingsSyncFailure).not.toHaveBeenCalled(); + expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", { + leg: "ecg", + }); + expect(persistedLeg()).toEqual({ + leg: "ecg", + value: { unavailable: ["heart"], checkedAt: expect.any(String) }, + }); + }); + + it("does not ask the Heart endpoint again while the refusal is fresh", async () => { + const fetchMock = install277(); + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({ + id: "connection-1", + apiPlanState: { + version: 1, + ecg: { unavailable: ["heart"], checkedAt: new Date().toISOString() }, + }, + } as never); + + const imported = await syncUserEcg("user-1"); + + expect(imported).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + expect(recordSyncFailure).not.toHaveBeenCalled(); + expect(prisma.$executeRaw).not.toHaveBeenCalled(); + }); + + it("re-probes after a week and forgets the refusal when the plan now allows it", async () => { + const fetchMock = installFetchMock([]); + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({ + id: "connection-1", + apiPlanState: { + version: 1, + ecg: { + unavailable: ["heart"], + checkedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(), + }, + }, + } as never); + + await syncUserEcg("user-1"); + + expect(fetchMock).toHaveBeenCalled(); + expect(persistedLeg()).toEqual({ + leg: "ecg", + value: { unavailable: [], checkedAt: expect.any(String) }, + }); + }); +}); diff --git a/src/lib/withings/__tests__/sync-plan.test.ts b/src/lib/withings/__tests__/sync-plan.test.ts new file mode 100644 index 000000000..24ab77259 --- /dev/null +++ b/src/lib/withings/__tests__/sync-plan.test.ts @@ -0,0 +1,480 @@ +/** + * Withings API plans (2026-10-12): a request naming one meastype outside the + * account's plan is refused whole with status 277. These tests drive the real + * measure sync and the real client against a mocked `safeFetch` that behaves + * like the plan: any refused meastype in `meastypes` answers 277, anything + * else answers with one reading per requested type. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + withingsConnection: { + findUnique: vi.fn(), + update: vi.fn(), + }, + integrationStatus: { + findMany: vi.fn(), + }, + $executeRaw: vi.fn(), + $transaction: vi.fn(), + }, +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn((v: string) => v), + encrypt: vi.fn((v: string) => v), +})); + +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: vi.fn(), +})); + +vi.mock("../credentials", () => ({ + getUserWithingsCredentials: vi.fn(async () => ({ + clientId: "cid", + clientSecret: "secret", + })), +})); + +vi.mock("@/lib/integrations/status", async () => { + const actual = await vi.importActual< + typeof import("@/lib/integrations/status") + >("@/lib/integrations/status"); + return { + ...actual, + isReauthRequired: vi.fn().mockResolvedValue(false), + recordSyncFailure: vi.fn().mockResolvedValue(undefined), + recordSyncSuccess: vi.fn().mockResolvedValue(undefined), + resumeIntegrationFromPark: vi.fn().mockResolvedValue({ wasParked: false }), + }; +}); + +vi.mock("@/lib/rollups/measurement-rollups", async () => { + const actual = await vi.importActual< + typeof import("@/lib/rollups/measurement-rollups") + >("@/lib/rollups/measurement-rollups"); + return { + ...actual, + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserDashboardSnapshot: vi.fn(), + invalidateUserMeasurements: vi.fn(), +})); + +vi.mock("@/lib/arrivals/measurement-emit", () => ({ + emitInsertedMeasurementArrivals: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: vi.fn(() => ({ + addExternalCall: vi.fn(), + addWarning: vi.fn(), + addMeta: vi.fn(), + toJSON: () => ({ meta: {} }), + })), + annotate: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { safeFetch } from "@/lib/safe-fetch"; +import { + recordSyncFailure, + recordSyncSuccess, + resumeIntegrationFromPark, +} from "@/lib/integrations/status"; +import { classifyWithingsResponse } from "../response-classifier"; +import { WithingsApiError } from "../response-classifier"; +import { + recordWithingsSyncFailure, + syncUserMeasurements, + WITHINGS_INCREMENTAL_OVERLAP_MS, +} from "../sync"; +import { + releasePlanGatedWithingsParks, + requestWithinPlan, + WITHINGS_ADVANCED_MEASTYPES, + WITHINGS_PLAN_MAX_REQUESTS, +} from "../api-plan"; +import { withingsPlanUnavailableFeatures } from "../plan-features"; + +const LAST_GOOD_SYNC = new Date("2026-10-12T21:12:00Z"); + +/** The meastypes the free plan refuses in these tests (the documented set). */ +let refused = new Set(WITHINGS_ADVANCED_MEASTYPES); + +/** Every `meastypes` list the client sent, in order. */ +const requestedTypes: number[][] = []; +const requestedStartdates: string[] = []; + +function installPlanFetch() { + vi.mocked(safeFetch).mockImplementation(async (_url, init) => { + const params = new URLSearchParams(String(init?.body ?? "")); + const types = (params.get("meastypes") ?? "") + .split(",") + .filter(Boolean) + .map(Number); + requestedTypes.push(types); + requestedStartdates.push(params.get("startdate") ?? ""); + const body = types.some((t) => refused.has(t)) + ? { + status: 277, + error: + "One of the data type requested in this API request is not available in your API plan", + } + : { + status: 0, + body: { + measuregrps: [ + { + grpid: 1, + date: Math.floor(Date.parse("2026-10-13T07:00:00Z") / 1000), + measures: types.map((type) => ({ type, value: 800, unit: -1 })), + }, + ], + more: 0, + }, + }; + return { status: 200, json: async () => body } as unknown as Response; + }); +} + +function connection( + apiPlanState: unknown = null, + lastSyncedAt = LAST_GOOD_SYNC, +) { + return { + id: "conn-1", + userId: "user-1", + withingsUserId: "wu-1", + accessToken: "enc-access", + refreshToken: "enc-refresh", + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + lastSyncedAt, + apiPlanState, + }; +} + +/** The legs the sync merged into `api_plan_state`, read off the raw SQL. */ +function persistedLegs(): Array<{ leg: unknown; value: unknown }> { + return vi.mocked(prisma.$executeRaw).mock.calls.map((call) => ({ + leg: call[1], + value: JSON.parse(String(call[2])), + })); +} + +function createdTypes(): string[] { + return vi + .mocked(prisma.measurement.create) + .mock.calls.map((c) => (c[0] as { data: { type: string } }).data.type); +} + +beforeEach(() => { + vi.clearAllMocks(); + requestedTypes.length = 0; + requestedStartdates.length = 0; + refused = new Set(WITHINGS_ADVANCED_MEASTYPES); + installPlanFetch(); + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue( + connection() as never, + ); + vi.mocked(prisma.withingsConnection.update).mockResolvedValue({} as never); + vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.create).mockImplementation((async (args: { + data: { type: string; measuredAt: Date }; + }) => ({ + id: `m-${args.data.type}`, + type: args.data.type, + measuredAt: args.data.measuredAt, + })) as never); + vi.mocked(prisma.$executeRaw).mockResolvedValue(1 as never); +}); + +describe("syncUserMeasurements inside the Withings API plan", () => { + it("retries without the advanced types on a 277 and imports the core ones", async () => { + const result = await syncUserMeasurements("user-1"); + + expect(requestedTypes).toHaveLength(2); + expect(requestedTypes[0]).toEqual( + expect.arrayContaining([...WITHINGS_ADVANCED_MEASTYPES, 1, 9, 10]), + ); + expect(requestedTypes[1]!.some((t) => refused.has(t))).toBe(false); + expect(requestedTypes[1]).toEqual(expect.arrayContaining([1, 9, 10, 11])); + + expect(result).toEqual({ imported: expect.any(Number), failed: false }); + expect(result.imported).toBeGreaterThan(0); + expect(createdTypes()).toEqual( + expect.arrayContaining([ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + ]), + ); + expect(createdTypes()).not.toContain("VASCULAR_AGE"); + + // A partial-by-plan run is a success, and the watermark moves on. + expect(recordSyncFailure).not.toHaveBeenCalled(); + expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", { + leg: "measures", + }); + expect(prisma.withingsConnection.update).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + data: { lastSyncedAt: expect.any(Date) }, + }); + + expect(persistedLegs()).toEqual([ + { + leg: "measures", + value: { + unavailable: [...WITHINGS_ADVANCED_MEASTYPES].sort((a, b) => a - b), + checkedAt: expect.any(String), + }, + }, + ]); + }); + + it("skips the remembered types on the next sync without a refused round trip", async () => { + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue( + connection({ + version: 1, + measures: { + unavailable: [...WITHINGS_ADVANCED_MEASTYPES], + checkedAt: new Date().toISOString(), + }, + }) as never, + ); + + const result = await syncUserMeasurements("user-1"); + + expect(requestedTypes).toHaveLength(1); + expect(requestedTypes[0]!.some((t) => refused.has(t))).toBe(false); + expect(result.failed).toBe(false); + expect(prisma.$executeRaw).not.toHaveBeenCalled(); + }); + + it("asks for the full set again once the remembered refusal is a week old", async () => { + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue( + connection({ + version: 1, + measures: { + unavailable: [...WITHINGS_ADVANCED_MEASTYPES], + checkedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(), + }, + }) as never, + ); + // The account moved to a plan that allows everything. + refused = new Set(); + + await syncUserMeasurements("user-1"); + + expect(requestedTypes).toHaveLength(1); + expect(requestedTypes[0]).toEqual( + expect.arrayContaining([...WITHINGS_ADVANCED_MEASTYPES]), + ); + expect(createdTypes()).toContain("VASCULAR_AGE"); + expect(persistedLegs()).toEqual([ + { + leg: "measures", + value: { unavailable: [], checkedAt: expect.any(String) }, + }, + ]); + }); + + it("narrows further when the reduced request is still refused", async () => { + // An undocumented refusal on top of the documented set: fat ratio (6). + refused = new Set([...WITHINGS_ADVANCED_MEASTYPES, 6]); + + const result = await syncUserMeasurements("user-1"); + + expect(result.failed).toBe(false); + expect(requestedTypes.length).toBeLessThanOrEqual( + WITHINGS_PLAN_MAX_REQUESTS, + ); + expect(createdTypes()).toEqual( + expect.arrayContaining(["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"]), + ); + expect(createdTypes()).not.toContain("BODY_FAT"); + const [persisted] = persistedLegs(); + expect(persisted?.value).toEqual({ + unavailable: [6, ...WITHINGS_ADVANCED_MEASTYPES].sort((a, b) => a - b), + checkedAt: expect.any(String), + }); + expect(recordSyncFailure).not.toHaveBeenCalled(); + }); + + it("reads the whole gap since the last good sync", async () => { + await syncUserMeasurements("user-1"); + + const expected = String( + Math.floor( + (LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000, + ), + ); + expect(requestedStartdates.every((s) => s === expected)).toBe(true); + }); + + it("holds the watermark and records no failure when the plan refuses everything", async () => { + refused = new Set([ + 1, 5, 6, 8, 9, 10, 11, 12, 35, 54, 71, 76, 77, 88, 91, 123, 155, 170, 73, + ]); + + const result = await syncUserMeasurements("user-1"); + + expect(result).toEqual({ imported: 0, failed: false }); + expect(prisma.withingsConnection.update).not.toHaveBeenCalled(); + expect(recordSyncFailure).not.toHaveBeenCalled(); + expect(requestedTypes.length).toBeLessThanOrEqual( + WITHINGS_PLAN_MAX_REQUESTS, + ); + }); +}); + +describe("a 277 is never an auth failure", () => { + it("classifies 277 as persistent, not reauth_required", () => { + expect(classifyWithingsResponse(200, { status: 277 }).classification).toBe( + "persistent", + ); + // The rest of the OAuth range keeps its meaning. + expect(classifyWithingsResponse(200, { status: 250 }).classification).toBe( + "reauth_required", + ); + }); + + it("keeps a stray 277 off the ledger entirely", async () => { + await recordWithingsSyncFailure( + "user-1", + new WithingsApiError({ + verb: "measure", + classification: "persistent", + withingsStatus: 277, + reason: "withings_277", + }), + "activity", + ); + expect(recordSyncFailure).not.toHaveBeenCalled(); + }); + + it("lifts connections an earlier build parked over a 277, and only those", async () => { + vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([ + { + userId: "plan-user", + lastError: + "Withings measure error: 277 - One of the data type requested in this API request is not available in your API plan", + }, + { userId: "revoked-user", lastError: "Withings refresh error: 101" }, + ] as never); + + const released = await releasePlanGatedWithingsParks(); + + expect(released).toBe(1); + expect(resumeIntegrationFromPark).toHaveBeenCalledTimes(1); + expect(resumeIntegrationFromPark).toHaveBeenCalledWith( + "plan-user", + "withings", + ); + }); +}); + +describe("requestWithinPlan", () => { + const gatedError = () => + new WithingsApiError({ + verb: "measure", + classification: "persistent", + withingsStatus: 277, + reason: "withings_277", + }); + + it("isolates refused keys deterministically and keeps every accepted part", async () => { + const refusedKeys = new Set(["c", "f"]); + const calls: string[][] = []; + const run = () => + requestWithinPlan({ + keys: ["a", "b", "c", "d", "e", "f", "g"], + knownGated: [], + fetch: async (keys: string[]) => { + calls.push(keys); + if (keys.some((k) => refusedKeys.has(k))) throw gatedError(); + return keys; + }, + }); + + const first = await run(); + const firstCalls = calls.splice(0); + const second = await run(); + + expect(first.unavailable.sort()).toEqual(["c", "f"]); + expect(first.results.sort()).toEqual(["a", "b", "d", "e", "g"]); + expect(first.gated).toBe(true); + expect(first.acceptedAny).toBe(true); + expect(calls).toEqual(firstCalls); + expect(second.unavailable.sort()).toEqual(["c", "f"]); + }); + + it("stops at the request budget and counts what is left as unavailable", async () => { + const result = await requestWithinPlan({ + keys: ["a", "b", "c", "d", "e", "f", "g", "h"], + knownGated: [], + maxRequests: 3, + fetch: async () => { + throw gatedError(); + }, + }); + expect(result.requests).toBe(3); + expect(result.unavailable.sort()).toEqual([ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + ]); + expect(result.acceptedAny).toBe(false); + }); + + it("lets any other error through untouched", async () => { + const boom = new Error("Withings measure error: 503"); + await expect( + requestWithinPlan({ + keys: [1, 2], + knownGated: [], + fetch: async () => { + throw boom; + }, + }), + ).rejects.toBe(boom); + }); +}); + +describe("withingsPlanUnavailableFeatures", () => { + it("names each withheld reading once, in display order", () => { + expect( + withingsPlanUnavailableFeatures({ + version: 1, + measures: { unavailable: [155, 35, 54, 91], checkedAt: "x" }, + sleepSummary: { unavailable: ["sleep_score"], checkedAt: "x" }, + ecg: { unavailable: ["heart"], checkedAt: "x" }, + }), + ).toEqual([ + "spo2", + "vascularAge", + "pulseWaveVelocity", + "ecg", + "sleepScore", + ]); + }); +}); diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts index bf414bfe0..e13894d40 100644 --- a/src/lib/withings/__tests__/sync-sleep.test.ts +++ b/src/lib/withings/__tests__/sync-sleep.test.ts @@ -23,6 +23,7 @@ vi.mock("@/lib/db", () => ({ updateMany: vi.fn(), }, withingsConnection: { findUnique: vi.fn() }, + $executeRaw: vi.fn(), $transaction: transactionMock, }, })); @@ -55,6 +56,7 @@ vi.mock("@/lib/logging/context", () => ({ addExternalCall: vi.fn(), addWarning: vi.fn(), })), + annotate: vi.fn(), })); // v1.4.39.1 — the sleep sync now folds the persistent measurement @@ -733,3 +735,106 @@ describe("syncUserSleep — measurement rollup hook (v1.4.39.1)", () => { expect(imported).toBe(1); }); }); + +describe("syncUserSleep — Withings API plan (status 277)", () => { + const ADVANCED = new Set(["sleep_score", "sdnn_1", "rr_average"]); + + /** Segments answer normally; the summary refuses any advanced field. */ + function installPlanFetch(refused: Set) { + const summaryFields: string[][] = []; + const nightEnd = Math.floor(Date.UTC(2026, 9, 13, 6) / 1000); + const fetchMock = vi.fn(async (_url: string, init: { body: string }) => { + const params = new URLSearchParams(init.body); + if (params.get("action") !== "getsummary") { + return { + status: 200, + json: async () => ({ status: 0, body: { series: [] } }), + }; + } + const fields = (params.get("data_fields") ?? "").split(","); + summaryFields.push(fields); + if (fields.some((f) => refused.has(f))) { + return { status: 200, json: async () => ({ status: 277 }) }; + } + const data = Object.fromEntries( + fields.map((f) => [f, f === "spo2_average" ? 95 : 58]), + ); + return { + status: 200, + json: async () => ({ + status: 0, + body: { + series: [ + { + id: 9, + startdate: nightEnd - 8 * 3600, + enddate: nightEnd, + data, + }, + ], + }, + }), + }; + }); + vi.stubGlobal("fetch", fetchMock); + return summaryFields; + } + + it("drops the advanced fields, imports the rest and remembers the refusal", async () => { + const summaryFields = installPlanFetch(ADVANCED); + reconcileMock.mockResolvedValue({ status: "inserted", row: {} }); + + const imported = await syncUserSleep("user-1"); + + expect(summaryFields).toEqual([ + ["hr_average", "rr_average", "sdnn_1", "spo2_average", "sleep_score"], + ["hr_average", "spo2_average"], + ]); + expect(imported).toBe(2); + expect(recordSyncFailure).not.toHaveBeenCalled(); + const call = vi.mocked(prisma.$executeRaw).mock.calls[0]!; + expect(call[1]).toBe("sleepSummary"); + expect(JSON.parse(String(call[2]))).toEqual({ + unavailable: ["rr_average", "sdnn_1", "sleep_score"], + checkedAt: expect.any(String), + }); + }); + + it("asks only for the allowed fields while the refusal is fresh", async () => { + const summaryFields = installPlanFetch(ADVANCED); + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({ + scope: "user.metrics,user.activity", + apiPlanState: { + version: 1, + sleepSummary: { + unavailable: ["sleep_score", "sdnn_1", "rr_average"], + checkedAt: new Date().toISOString(), + }, + }, + } as never); + reconcileMock.mockResolvedValue({ status: "inserted", row: {} }); + + await syncUserSleep("user-1"); + + expect(summaryFields).toEqual([["hr_average", "spo2_average"]]); + expect(prisma.$executeRaw).not.toHaveBeenCalled(); + }); + + it("does not fail the leg when the stage timeline itself is refused", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + status: 200, + json: async () => ({ status: 277 }), + })), + ); + + const imported = await syncUserSleep("user-1"); + + expect(imported).toBe(0); + expect(recordSyncFailure).not.toHaveBeenCalled(); + expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", { + leg: "sleep", + }); + }); +}); diff --git a/tests/integration/oura-scope-denied.test.ts b/tests/integration/oura-scope-denied.test.ts new file mode 100644 index 000000000..28100d5a2 --- /dev/null +++ b/tests/integration/oura-scope-denied.test.ts @@ -0,0 +1,61 @@ +/** + * The Oura scope note against the real schema: the sync writes the + * collections a grant does not cover, writes nothing when the set is + * unchanged, clears it when the grant covers everything, and the connection + * read hands it back to the next sync. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-oura-scope"; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: "oura-scope", + email: "oura-scope@example.test", + ouraAccessTokenEncrypted: encrypt("acc"), + ouraRefreshTokenEncrypted: encrypt("ref"), + }, + }); +}); + +async function stored() { + const row = await getPrismaClient().user.findUnique({ + where: { id: USER }, + select: { ouraScopeDenied: true, updatedAt: true }, + }); + return row!; +} + +describe("syncOuraScopeDenied", () => { + it("stores, keeps, hands back and clears the scope gap", async () => { + const { syncOuraScopeDenied, getOuraConnection } = + await import("@/lib/oura/credentials"); + + await syncOuraScopeDenied(USER, ["vo2max", "spo2"]); + const first = await stored(); + expect(first.ouraScopeDenied).toEqual(["spo2", "vo2max"]); + expect((await getOuraConnection(USER))?.scopeDenied).toEqual([ + "spo2", + "vo2max", + ]); + + // Same set in another order: no write. + await syncOuraScopeDenied(USER, ["spo2", "vo2max"]); + expect((await stored()).updatedAt).toEqual(first.updatedAt); + + await syncOuraScopeDenied(USER, []); + expect((await stored()).ouraScopeDenied).toBeNull(); + expect((await getOuraConnection(USER))?.scopeDenied).toEqual([]); + }); +}); diff --git a/tests/integration/withings-api-plan.test.ts b/tests/integration/withings-api-plan.test.ts new file mode 100644 index 000000000..f69219e69 --- /dev/null +++ b/tests/integration/withings-api-plan.test.ts @@ -0,0 +1,232 @@ +/** + * Withings API plans (2026-10-12) against the real schema: the plan memory is + * merged per leg by one SQL statement, a measure sync refused with 277 still + * imports what the plan allows, and a connection an earlier build parked over + * a 277 is lifted. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-withings-api-plan"; +const LAST_GOOD_SYNC = new Date(Date.now() - 2 * 86_400_000); +const ADVANCED = new Set([91, 155, 170, 123, 73]); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: "withings-api-plan", + email: "withings-api-plan@example.test", + }, + }); + await prisma.withingsConnection.create({ + data: { + userId: USER, + withingsUserId: "wu-plan", + accessToken: encrypt("access-token"), + refreshToken: encrypt("refresh-token"), + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + scope: "user.metrics,user.activity", + lastSyncedAt: LAST_GOOD_SYNC, + }, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** A plan that refuses the documented advanced meastypes with 277. */ +function stubFreePlan() { + const calls: number[][] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init: { body: string }) => { + const types = (new URLSearchParams(init.body).get("meastypes") ?? "") + .split(",") + .map(Number); + calls.push(types); + if (types.some((t) => ADVANCED.has(t))) { + return { status: 200, json: async () => ({ status: 277 }) }; + } + return { + status: 200, + json: async () => ({ + status: 0, + body: { + measuregrps: [ + { + grpid: 1, + date: Math.floor((Date.now() - 86_400_000) / 1000), + measures: [ + { type: 1, value: 8120, unit: -2 }, + { type: 10, value: 128, unit: 0 }, + { type: 9, value: 82, unit: 0 }, + ], + }, + ], + more: 0, + }, + }), + }; + }), + ); + return calls; +} + +describe("Withings plan memory", () => { + it("merges each leg without dropping the other", async () => { + const { persistWithingsPlanLeg, parseWithingsApiPlanState } = + await import("@/lib/withings/api-plan"); + await persistWithingsPlanLeg(USER, "measures", { + unavailable: [155, 91], + checkedAt: "2026-10-14T10:00:00.000Z", + }); + await persistWithingsPlanLeg(USER, "ecg", { + unavailable: ["heart"], + checkedAt: "2026-10-14T10:41:00.000Z", + }); + + const row = await getPrismaClient().withingsConnection.findUnique({ + where: { userId: USER }, + select: { apiPlanState: true }, + }); + expect(parseWithingsApiPlanState(row?.apiPlanState)).toEqual({ + version: 1, + measures: { + unavailable: [155, 91], + checkedAt: "2026-10-14T10:00:00.000Z", + }, + ecg: { unavailable: ["heart"], checkedAt: "2026-10-14T10:41:00.000Z" }, + }); + }); + + it("replaces a stored value that is not a v1 object", async () => { + const prisma = getPrismaClient(); + await prisma.withingsConnection.update({ + where: { userId: USER }, + data: { apiPlanState: { version: 2, measures: "junk" } }, + }); + const { persistWithingsPlanLeg } = await import("@/lib/withings/api-plan"); + await persistWithingsPlanLeg(USER, "sleepSummary", { + unavailable: ["sleep_score"], + checkedAt: "2026-10-14T10:15:00.000Z", + }); + + const row = await prisma.withingsConnection.findUnique({ + where: { userId: USER }, + select: { apiPlanState: true }, + }); + expect(row?.apiPlanState).toEqual({ + version: 1, + sleepSummary: { + unavailable: ["sleep_score"], + checkedAt: "2026-10-14T10:15:00.000Z", + }, + }); + }); +}); + +describe("syncUserMeasurements on the free plan", () => { + it("imports the core types, remembers the refusal and skips it next time", async () => { + const calls = stubFreePlan(); + const { syncUserMeasurements } = await import("@/lib/withings/sync"); + + const first = await syncUserMeasurements(USER); + expect(first.failed).toBe(false); + expect(first.imported).toBe(3); + expect(calls).toHaveLength(2); + + const prisma = getPrismaClient(); + const conn = await prisma.withingsConnection.findUnique({ + where: { userId: USER }, + select: { apiPlanState: true, lastSyncedAt: true }, + }); + expect(conn?.lastSyncedAt?.getTime()).toBeGreaterThan( + LAST_GOOD_SYNC.getTime(), + ); + expect( + (conn?.apiPlanState as { measures: { unavailable: number[] } }).measures + .unavailable, + ).toEqual([73, 91, 123, 155, 170]); + + const rows = await prisma.measurement.findMany({ + where: { userId: USER, source: "WITHINGS" }, + select: { type: true }, + }); + expect(rows.map((r) => r.type).sort()).toEqual([ + "BLOOD_PRESSURE_DIA", + "BLOOD_PRESSURE_SYS", + "WEIGHT", + ]); + const status = await prisma.integrationStatus.findUnique({ + where: { userId_integration: { userId: USER, integration: "withings" } }, + select: { state: true }, + }); + expect(status?.state).toBe("connected"); + + calls.length = 0; + await syncUserMeasurements(USER); + expect(calls).toHaveLength(1); + expect(calls[0]!.some((t) => ADVANCED.has(t))).toBe(false); + }); +}); + +describe("releasePlanGatedWithingsParks", () => { + it("lifts a park recorded over a 277 and leaves a revoked grant parked", async () => { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: "user-revoked", + username: "revoked", + email: "revoked@example.test", + }, + }); + await prisma.integrationStatus.createMany({ + data: [ + { + userId: USER, + integration: "withings", + state: "error_reauth", + lastError: encrypt( + "Withings measure error: 277 - One of the data type requested in this API request is not available in your API plan", + ), + consecutiveFailuresByKind: { + transient: 0, + reauth_required: 30, + persistent: 0, + }, + failingLegs: ["measures", "ecg"], + }, + { + userId: "user-revoked", + integration: "withings", + state: "error_reauth", + lastError: encrypt("Withings refresh error: 101 - invalid token"), + }, + ], + }); + + const { releasePlanGatedWithingsParks } = + await import("@/lib/withings/api-plan"); + expect(await releasePlanGatedWithingsParks()).toBe(1); + + const rows = await prisma.integrationStatus.findMany({ + orderBy: { userId: "asc" }, + select: { userId: true, state: true, failingLegs: true }, + }); + expect(rows).toEqual([ + { userId: "user-revoked", state: "error_reauth", failingLegs: null }, + { userId: USER, state: "connected", failingLegs: [] }, + ]); + }); +});