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: [] },
+ ]);
+ });
+});