Files
HealthLog/tests/integration/withings-api-plan.test.ts

233 lines
7.1 KiB
TypeScript

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