test(integrations): pin the Withings plan handling and the Oura scope gap; document the plan change

This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:08:22 +02:00
1 parent 044f9008ba
commit 4793edb961
10 files changed
+1257 -1

No files matched your search

+55
View File
@@ -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 <https://developer.withings.com/> 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
@@ -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<typeof import("@/lib/oura/credentials")>(
"@/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<typeof vi.fn>
).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([]);
});
});
@@ -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(
<I18nProvider initialLocale="en">
<OAuthProviderCard
provider="oura"
statusQueryKey={["oura"]}
i18nPrefix="settings.oura"
icon={Watch}
dataHref="/insights/sleep"
viewModel={viewModel}
callbackUrl="https://app.example/api/oura/callback"
/>
</I18nProvider>,
);
}
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");
});
});
@@ -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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<WithingsPlanNote>", () => {
it("lists the withheld readings and links the Withings plans page", () => {
const html = render(
<WithingsPlanNote features={["spo2", "vascularAge", "ecg"]} />,
);
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(<WithingsPlanNote features={["vascularAge"]} />, "de");
expect(html).toContain("Gefäßalter");
expect(html).toContain("kostenpflichtigen API-Plan");
});
it("renders nothing when the plan withholds nothing", () => {
expect(render(<WithingsPlanNote features={[]} />)).toBe("");
expect(render(<WithingsPlanNote features={undefined} />)).toBe("");
});
});
describe("<OuraScopeNote>", () => {
it("asks for one reconnect naming the collections the grant lacks", () => {
const html = render(
<OuraScopeNote
collections={["spo2", "vo2max", "cardio_age", "resilience"]}
onReconnect={vi.fn()}
/>,
);
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(<OuraScopeNote collections={[]} onReconnect={vi.fn()} />),
).toBe("");
});
});
+81
View File
@@ -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" }),
);
});
});
@@ -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) },
});
});
});
@@ -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<number>(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<number>(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",
]);
});
});
@@ -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<string>) {
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",
});
});
});
@@ -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([]);
});
});
+232
View File
@@ -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: [] },
]);
});
});