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

@@ -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",
});
});
});