mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
test(integrations): pin the Withings plan handling and the Oura scope gap; document the plan change
This commit is contained in:
1 parent
044f9008ba
commit
4793edb961
10 files changed
+1257
-1
No files matched your search
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user