Files
HealthLog/src/lib/withings/__tests__/sync-sleep.test.ts
T
Marc-André Bombeck c1921d1dd1 fix(withings): read the sleep stages night by night
Sleep v2 Get answers only the first 24 hours after its startdate. The
sync asked for the whole 30-day window, so it got the night a month
back and nothing since: every night's stages arrived when the sliding
window reached it, weeks late.

The summary now runs first and lists the nights; each night's stages
are read over its own span (at most 24 hours). Every run re-reads the
nights that ended, or that Withings changed, in the last 48 hours, and
fills older nights that hold no stage row yet, eight per run with a
rotation so a night without stages cannot hold up the rest; a full
sync reads them all. Without the night list the last 24 hours are read
as one slice and nothing is swept, since a slice can cut a night.

A summary failure used to be a warning beside a green leg. It is now
the sleep leg's failure, recorded after the stages land.
2026-10-21 12:59:43 +02:00

1042 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.4.25 — Withings Sleep v2 sync unit tests.
*
* Coverage focuses on the state → SleepStage enum mapping, the
* unix-seconds vs minutes conversion (easy regression trap), and the
* per-segment write path. End-to-end DB exercise lives in the
* integration suite.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { reconcileMock, transactionMock } = vi.hoisted(() => ({
reconcileMock: vi.fn(),
transactionMock: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
withingsConnection: { findUnique: vi.fn() },
$executeRaw: vi.fn(),
$transaction: transactionMock,
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: reconcileMock,
MeasurementReconciliationError: class extends Error {},
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn().mockResolvedValue(false),
parkIntegrationAtReauth: vi.fn(),
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
}));
vi.mock("../sync", async () => {
const actual = await vi.importActual<typeof import("../sync")>("../sync");
return {
...actual,
getValidToken: vi.fn(async () => ({
accessToken: "token",
connection: { id: "conn-1", withingsUserId: "wu-1" },
})),
};
});
vi.mock("@/lib/logging/context", () => ({
getEvent: vi.fn(() => ({
addExternalCall: vi.fn(),
addWarning: vi.fn(),
})),
annotate: vi.fn(),
}));
// v1.4.39.1 — the sleep sync now folds the persistent measurement
// rollup tier for each touched SLEEP_DURATION day so the dashboard
// chart's `source=rollup` fast-path sees the new segments. The mock
// surfaces the call args for the regression test below.
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
const perKey = vi.fn().mockResolvedValue(undefined);
return {
...actual,
recomputeBucketsForMeasurement: perKey,
// The keyed refold, observed as the per-key recomputes it stands for.
recomputeBucketsForKeys: vi.fn(
async (
userId: string,
keys: Array<{ type: string; measuredAt: Date }>,
) => {
for (const k of keys) await perKey(userId, k.type, k.measuredAt);
},
),
};
});
import { prisma } from "@/lib/db";
import {
parkIntegrationAtReauth,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import {
fetchWithingsSleep,
mapWithingsSleepState,
mapWithingsSleepSummary,
syncUserSleep,
} from "../sync-sleep";
interface FakeSegment {
startdate: number;
enddate: number;
state: number;
id?: number;
}
type FakeSummary = {
id?: number;
startdate: number;
enddate: number;
modified?: number;
data?: Record<string, number | null>;
};
/** The night list Withings would derive from these segments, one per session. */
function nightsOf(segments: FakeSegment[]): FakeSummary[] {
const bySession = new Map<string, FakeSummary>();
for (const seg of segments) {
const key = String(seg.id ?? "no-id");
const night = bySession.get(key);
bySession.set(
key,
night
? {
...night,
startdate: Math.min(night.startdate, seg.startdate),
enddate: Math.max(night.enddate, seg.enddate),
}
: { id: seg.id, startdate: seg.startdate, enddate: seg.enddate },
);
}
return [...bySession.values()];
}
/**
* Answer the two Sleep v2 actions the way Withings does: `getsummary` lists
* the nights, `get` returns the segments inside the requested span, and no
* more than its first 24 hours.
*/
function installSleepApi(segments: FakeSegment[], summaries: FakeSummary[]) {
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: summaries } }),
};
}
// Sleep v2 Get: "only the first 24h after startdate will be returned".
const from = Number(params.get("startdate"));
const to = Math.min(Number(params.get("enddate")), from + 24 * 3600);
return {
status: 200,
json: async () => ({
status: 0,
body: {
series: segments.filter(
(seg) => seg.startdate >= from && seg.enddate <= to,
),
},
}),
};
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function installFetchMock(segments: FakeSegment[]) {
return installSleepApi(segments, nightsOf(segments));
}
beforeEach(() => {
vi.resetAllMocks();
transactionMock.mockImplementation(async (run: (tx: unknown) => unknown) =>
run({}),
);
reconcileMock.mockImplementation(
async (
_tx: unknown,
input: { type: string; measuredAt: Date; externalId: string },
) => ({
status: "inserted",
row: {
id: `inserted:${input.externalId}`,
type: input.type,
measuredAt: input.measuredAt,
externalId: input.externalId,
},
}),
);
// v1.4.26 — every syncUserSleep call now reads the connection's
// scope to short-circuit legacy `user.metrics`-only connections.
// Default-mock to "scope is fine" so the existing segment-mapping
// tests stay focused on the write path. The scope-skip case has
// its own dedicated tests below.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics,user.activity",
} as never);
// No stage row stored yet: every listed night is one to read.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/**
* v1.18.10 — fetch mock that returns the per-segment series on the first
* call (`action=get`) and the per-night summary series on the second
* (`action=getsummary`). Lets the sync exercise both the stage path and the
* nightly-vital path in one round-trip.
*/
function installSegmentThenSummaryFetch(
segments: FakeSegment[],
summaries: FakeSummary[],
) {
return installSleepApi(segments, summaries);
}
describe("mapWithingsSleepSummary", () => {
it("maps the canonical nightly vitals to existing enums", () => {
const rows = mapWithingsSleepSummary({
hr_average: 58,
rr_average: 14,
sdnn_1: 65,
spo2_average: 96,
sleep_score: 82,
});
const byType = Object.fromEntries(rows.map((r) => [r.type, r.value]));
expect(byType).toEqual({
RESTING_HEART_RATE: 58,
RESPIRATORY_RATE: 14,
HEART_RATE_VARIABILITY: 65,
OXYGEN_SATURATION: 96,
SLEEP_SCORE: 82,
});
});
it("normalises a fractional SpO2 (0..1) to percent", () => {
const rows = mapWithingsSleepSummary({ spo2_average: 0.97 });
const spo2 = rows.find((r) => r.type === "OXYGEN_SATURATION");
expect(spo2?.value).toBeCloseTo(97, 5);
});
it("skips omitted / non-finite fields", () => {
const rows = mapWithingsSleepSummary({
hr_average: 60,
rr_average: null,
sdnn_1: undefined as unknown as number,
sleep_score: Number.NaN,
});
expect(rows.map((r) => r.type)).toEqual(["RESTING_HEART_RATE"]);
});
it("returns nothing for an absent data block", () => {
expect(mapWithingsSleepSummary(undefined)).toEqual([]);
});
});
describe("mapWithingsSleepState", () => {
it("maps state 0 (awake) → AWAKE", () => {
expect(mapWithingsSleepState(0)).toBe("AWAKE");
});
it("maps state 1 (light) → CORE (HealthKit-aligned NREM 1+2)", () => {
expect(mapWithingsSleepState(1)).toBe("CORE");
});
it("maps state 2 (deep) → DEEP", () => {
expect(mapWithingsSleepState(2)).toBe("DEEP");
});
it("maps state 3 (REM) → REM", () => {
expect(mapWithingsSleepState(3)).toBe("REM");
});
it("returns null for state 4 (synthetic marker — ignored)", () => {
expect(mapWithingsSleepState(4)).toBeNull();
});
it("returns null for any unknown state value", () => {
expect(mapWithingsSleepState(99)).toBeNull();
expect(mapWithingsSleepState(-1)).toBeNull();
});
});
describe("fetchWithingsSleep", () => {
it("POSTs sleep get with unix-seconds startdate + enddate", async () => {
const fetchMock = installFetchMock([]);
await fetchWithingsSleep("token", 1715000000, 1715100000);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe("https://wbsapi.withings.net/v2/sleep");
const body = String(init.body);
expect(body).toContain("action=get");
expect(body).toContain("startdate=1715000000");
expect(body).toContain("enddate=1715100000");
});
it("throws when Withings returns a non-zero status", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({ status: 293 }),
})),
);
await expect(
fetchWithingsSleep("token", 1715000000, 1715100000),
).rejects.toThrow(/Withings sleep error: 293/);
});
});
describe("syncUserSleep — segment writes + idempotency", () => {
it("writes one row per stage segment with the mapped SleepStage", async () => {
// A typical night: 4 segments — light, deep, REM, light.
const base = 1715000000;
installFetchMock([
{ startdate: base, enddate: base + 3600, state: 1, id: 99 }, // 60 min CORE
{ startdate: base + 3600, enddate: base + 5400, state: 2, id: 99 }, // 30 min DEEP
{ startdate: base + 5400, enddate: base + 7200, state: 3, id: 99 }, // 30 min REM
{ startdate: base + 7200, enddate: base + 7800, state: 0, id: 99 }, // 10 min AWAKE
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(4);
const writes = reconcileMock.mock.calls.map((call) => call[1]);
expect(writes.map((row) => row.sleepStage)).toEqual([
"CORE",
"DEEP",
"REM",
"AWAKE",
]);
expect(writes[0]).toMatchObject({ value: 60, unit: "minutes" });
});
it("stamps measuredAt at the segment END (enddate, unix seconds → Date)", async () => {
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 2, id: 1 },
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
await syncUserSleep("user-1");
const row = reconcileMock.mock.calls[0]![1] as { measuredAt: Date };
// measuredAt is the segment END — every reader treats it as the END and
// resolves the span as start = end − duration. Stamping the START shifted
// the night one segment-length earlier.
expect(row.measuredAt.getTime()).toBe(1715003600 * 1000);
});
it("skips state 4 (synthetic marker) without throwing", async () => {
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 4, id: 1 },
{ startdate: 1715003600, enddate: 1715007200, state: 2, id: 1 },
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(1);
const stages = reconcileMock.mock.calls.map((call) => call[1].sleepStage);
expect(stages).toEqual(["DEEP"]);
});
it("updates existing rows on a re-sync rather than inserting duplicates", async () => {
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 2, id: 1 },
]);
reconcileMock.mockResolvedValueOnce({
status: "updated",
row: {
id: "row-1",
type: "SLEEP_DURATION",
measuredAt: new Date(1715003600 * 1000),
externalId: "withings:sleep:user-1:1:1715000000",
},
});
const imported = await syncUserSleep("user-1");
expect(imported).toBe(1);
expect(reconcileMock.mock.calls[0]![1]).toMatchObject({
value: 60,
measuredAt: new Date(1715003600 * 1000),
sleepStage: "DEEP",
externalId: "withings:sleep:user-1:1:1715000000",
});
});
it("re-keys a re-scored segment in place via the natural key instead of colliding (F3, 0055 wedge)", async () => {
// Withings re-scores a night: the segment's END (measuredAt + the 0055
// natural key) stays fixed while its START (externalId) shifts, same stage.
// The externalId probe MISSES (new START ⇒ new id), but the natural key
// `(userId, type, measuredAt, source, sleepStage)` is still occupied by the
// prior row. A blind create would P2002 on it (swallowed → row lost, then
// the sweep tombstones the old row → the night wedges forever). The
// natural-key rescue must UPDATE the surviving row in place, re-keying it.
installFetchMock([
{ startdate: 1715000500, enddate: 1715003600, state: 2, id: 42 },
]);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(1);
expect(reconcileMock.mock.calls[0]![1]).toMatchObject({
value: expect.any(Number),
measuredAt: new Date(1715003600 * 1000),
sleepStage: "DEEP",
externalId: "withings:sleep:user-1:42:1715000500",
});
});
it("stamps every row with an externalId keyed on session id + segment START", async () => {
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 2, id: 42 },
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
await syncUserSleep("user-1");
const row = reconcileMock.mock.calls[0]![1] as { externalId: string };
// Session id + the segment's own startdate — both stable within a session.
// The retired running index counted across the whole rolling 30-day fetch
// window, so the window slide renumbered every segment on each sync and a
// re-aggregated night inserted a duplicate set.
expect(row.externalId).toBe("withings:sleep:user-1:42:1715000000");
});
it("mints IDENTICAL externalIds when the fetch window slides (re-fetch of the same night)", async () => {
// The same two segments of session 42, fetched twice: the second fetch's
// window has slid so the series now carries an EXTRA leading segment from
// a different session — under the retired running index this shifted
// every index of session 42 (0,1 → 1,2), minting fresh ids that inserted
// a duplicate night. The start-keyed ids must be identical across fetches.
const night = [
{ startdate: 1715000000, enddate: 1715003600, state: 1, id: 42 },
{ startdate: 1715003600, enddate: 1715005400, state: 2, id: 42 },
];
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
installFetchMock(night);
await syncUserSleep("user-1");
const firstIds = reconcileMock.mock.calls.map(
(call) => call[1].externalId as string,
);
reconcileMock.mockClear();
installFetchMock([
{ startdate: 1714990000, enddate: 1714993600, state: 1, id: 7 },
...night,
]);
await syncUserSleep("user-1");
const secondIds = reconcileMock.mock.calls
.map((call) => call[1].externalId as string)
.filter((id) => id.includes(":42:"));
expect(secondIds).toEqual(firstIds);
});
it("sweeps stale rows per fetched session: live-only, session-prefixed, notIn the fresh set, soft-delete (v1.28.25)", async () => {
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 1, id: 42 },
{ startdate: 1715003600, enddate: 1715005400, state: 2, id: 42 },
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 0,
} as never);
await syncUserSleep("user-1");
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
const arg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0] as {
where: Record<string, unknown>;
data: Record<string, unknown>;
};
expect(arg.where).toEqual({
userId: "user-1",
source: "WITHINGS",
type: "SLEEP_DURATION",
deletedAt: null,
externalId: {
startsWith: "withings:sleep:user-1:42:",
notIn: [
"withings:sleep:user-1:42:1715000000",
"withings:sleep:user-1:42:1715003600",
],
},
});
expect(arg.data).toEqual({ deletedAt: expect.any(Date) });
// A legacy running-index row for this session falls inside the sweep:
// under the session prefix, never in the fresh start-keyed set.
const where = arg.where as {
externalId: { startsWith: string; notIn: string[] };
};
const legacyId = "withings:sleep:user-1:42:0";
expect(legacyId.startsWith(where.externalId.startsWith)).toBe(true);
expect(where.externalId.notIn).not.toContain(legacyId);
});
it("never sweeps sessions absent from this fetch (no id → no sweep entry)", async () => {
// Segments without a session id cannot be bounded to one night — their
// shared `no-id` prefix would span every id-less night in history — so
// they must not produce a sweep.
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 2 },
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(1);
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
it("calls recordSyncSuccess after a clean round-trip", async () => {
installFetchMock([]);
await syncUserSleep("user-1");
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
leg: "sleep",
});
});
it("W-3 — records a transient failure and rethrows when a sleep write throws", async () => {
// A reconcile throw out of the stage transaction used to propagate past the
// success stamp and get swallowed by both callers: neither success NOR
// failure recorded, `lastAttemptAt` quietly stale. Now the source records a
// transient failure and rethrows (callers keep their warn-and-continue).
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 2, id: 1 },
]);
reconcileMock.mockReset().mockResolvedValueOnce({
status: "failed",
reason: "db_error",
error: new Error("reconcile boom"),
});
await expect(syncUserSleep("user-1")).rejects.toThrow();
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({
integration: "withings",
kind: "transient",
message: expect.stringContaining("sleep write failed"),
}),
);
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("upserts nightly vitals with stable per-vital externalIds (v1.18.10 P0)", async () => {
const nightEnd = Math.floor(Date.UTC(2026, 4, 13, 6) / 1000);
installSegmentThenSummaryFetch(
[],
[
{
id: 555,
startdate: nightEnd - 8 * 3600,
enddate: nightEnd,
data: {
hr_average: 57,
rr_average: 13,
sdnn_1: 70,
spo2_average: 95,
sleep_score: 88,
},
},
],
);
reconcileMock.mockResolvedValue({ status: "updated", row: {} });
const imported = await syncUserSleep("user-1");
// Five vitals upserted (no stage segments in this fixture).
expect(imported).toBe(5);
const byType = new Map(
reconcileMock.mock.calls.map((call) => {
const row = call[1] as {
type: string;
externalId: string;
value: number;
};
return [row.type, { externalId: row.externalId, value: row.value }];
}),
);
expect(byType.get("RESTING_HEART_RATE")).toEqual({
externalId: "withings:sleep:user-1:555:hr",
value: 57,
});
expect(byType.get("SLEEP_SCORE")).toEqual({
externalId: "withings:sleep:user-1:555:score",
value: 88,
});
expect(byType.get("OXYGEN_SATURATION")?.value).toBe(95);
});
it("reads the last 24 hours and books the leg failed when the summary fails", async () => {
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
const params = new URLSearchParams(init.body);
return {
status: 200,
json: async () =>
params.get("action") === "getsummary"
? { status: 293 } // summary errors
: {
status: 0,
body: {
series: [
{
startdate: 1715000000,
enddate: 1715003600,
state: 2,
id: 1,
},
],
},
},
};
});
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserSleep("user-1");
// Without the night list one slice of the last 24 hours is read, so
// last night still lands; it is not swept, since a slice can cut a night.
expect(imported).toBe(1);
const stageCalls = fetchMock.mock.calls
.map(([, init]) => new URLSearchParams(init.body))
.filter((p) => p.get("action") === "get");
expect(stageCalls).toHaveLength(1);
expect(
Number(stageCalls[0]!.get("enddate")) -
Number(stageCalls[0]!.get("startdate")),
).toBe(24 * 3600);
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
// The failure is the leg's, not a warning beside a green leg.
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({ integration: "withings", leg: "sleep" }),
);
});
it("tolerates a ScanWatch night with no REM segment (no all-zeros row)", async () => {
// ScanWatch reports CORE + DEEP only; a missing REM should NOT
// synthesise an all-zeros REM row. Withings simply omits the
// segment, so the writer never sees it.
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 1, id: 7 },
{ startdate: 1715003600, enddate: 1715005400, state: 2, id: 7 },
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(2);
const stages = reconcileMock.mock.calls.map((call) => call[1].sleepStage);
expect(stages).not.toContain("REM");
});
});
describe("syncUserSleep — scope-skip guard (v1.4.26)", () => {
it("returns 0 without firing the Withings call when scope lacks user.activity", async () => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics",
} as never);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(reconcileMock).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("parks the connection via parkIntegrationAtReauth (NOT recordSyncFailure) — v1.4.27 F20 + BL-P3-2 parity", async () => {
// BL-P3-2 — sleep mirrors activity. The deliberate scope-skip is a
// no-op park, not a failure burst; calling `recordSyncFailure`
// here would increment the counter and could trip the 3-strike
// admin alert ladder. Swap to `parkIntegrationAtReauth`.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics",
} as never);
vi.stubGlobal("fetch", vi.fn());
await syncUserSleep("user-1");
expect(parkIntegrationAtReauth).toHaveBeenCalledTimes(1);
expect(parkIntegrationAtReauth).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
integration: "withings",
errorCode: "scope_missing",
}),
);
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("treats a null scope (pre-v1.4.25 connection) as missing user.activity and parks silently", async () => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: null,
} as never);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(parkIntegrationAtReauth).toHaveBeenCalledWith(
expect.objectContaining({ errorCode: "scope_missing" }),
);
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("classifies a Withings 403 in the catch-block as reauth_required and STILL pages (defence-in-depth)", async () => {
// BL-P3-2 — symmetric to sync-activity. The catch-block stays on
// `recordSyncFailure` because a 403 reaching the catch IS
// genuinely unexpected after the scope-skip lands above. The
// 3-strike alert ladder fires here.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics,user.activity",
} as never);
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({ status: 403, error: "insufficient scope" }),
})),
);
await expect(syncUserSleep("user-1")).rejects.toThrow(
/Withings sleep error: 403/,
);
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({
kind: "reauth_required",
errorCode: "403",
}),
);
expect(parkIntegrationAtReauth).not.toHaveBeenCalled();
});
});
describe("syncUserSleep — measurement rollup hook (v1.4.39.1)", () => {
it("folds the persistent rollup table once per night the sync touched", async () => {
// Two distinct nights, each with multiple stage segments. The
// sync should collapse the per-night segments to one rollup
// recompute per night via `collapseToTypeDayKeys`. Pre-v1.4.39.1
// the rollup tier never heard about Withings sleep at all.
const nightA = Math.floor(Date.UTC(2026, 4, 12, 22) / 1000); // 2026-05-12 22:00 UTC
const nightB = Math.floor(Date.UTC(2026, 4, 13, 22) / 1000); // 2026-05-13 22:00 UTC
installFetchMock([
{ startdate: nightA, enddate: nightA + 3600, state: 1, id: 1 },
{ startdate: nightA + 3600, enddate: nightA + 5400, state: 2, id: 1 },
{ startdate: nightA + 5400, enddate: nightA + 7200, state: 3, id: 1 },
{ startdate: nightB, enddate: nightB + 3600, state: 1, id: 2 },
{ startdate: nightB + 3600, enddate: nightB + 5400, state: 2, id: 2 },
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
await syncUserSleep("user-1");
// Both segments of night A land in the same UTC day; night B in
// the next. Distinct (SLEEP_DURATION, dayStart) tuples = 2.
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(2);
const types = vi
.mocked(recomputeBucketsForMeasurement)
.mock.calls.map((c) => c[1]);
expect(new Set(types)).toEqual(new Set(["SLEEP_DURATION"]));
});
it("swallows a populator failure so the sync still returns its imported count", async () => {
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 2, id: 1 },
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
vi.mocked(recomputeBucketsForMeasurement).mockRejectedValueOnce(
new Error("simulated rollup failure"),
);
const imported = await syncUserSleep("user-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",
});
});
});
describe("syncUserSleep — one stage read per night (Sleep v2 Get answers 24 h)", () => {
const NOW = new Date("2026-10-21T09:00:00.000Z");
const nowUnix = Math.floor(NOW.getTime() / 1000);
const DAY = 24 * 3600;
/** Night `n` days back: 22:00 to 06:00, one CORE and one DEEP segment. */
function night(n: number, id: number) {
const end = nowUnix - 3 * 3600 - n * DAY; // 06:00 that morning
const startdate = end - 8 * 3600;
return {
summary: { id, startdate, enddate: end },
segments: [
{ startdate, enddate: startdate + 4 * 3600, state: 1, id },
{ startdate: startdate + 4 * 3600, enddate: end, state: 2, id },
],
};
}
function stageReads(fetchMock: ReturnType<typeof installSleepApi>) {
return fetchMock.mock.calls
.map(([, init]) => new URLSearchParams(init.body))
.filter((p) => p.get("action") === "get")
.map((p) => [Number(p.get("startdate")), Number(p.get("enddate"))]);
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it("reads every listed night with its own span and lands all of them", async () => {
// Before, one read over the 30-day window answered only its first 24
// hours: last night's stages came weeks late.
const nights = [0, 3, 9].map((n, i) => night(n, 100 + i));
const fetchMock = installSleepApi(
nights.flatMap((x) => x.segments),
nights.map((x) => x.summary),
);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(6);
const bySpan = (a: number[], b: number[]) => a[0]! - b[0]!;
expect([...stageReads(fetchMock)].sort(bySpan)).toEqual(
nights.map((x) => [x.summary.startdate, x.summary.enddate]).sort(bySpan),
);
for (const [from, to] of stageReads(fetchMock)) {
expect(to - from).toBeLessThanOrEqual(DAY);
}
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
leg: "sleep",
});
});
it("re-reads the recent nights every run and skips older nights already stored", async () => {
const recent = night(0, 1);
const stored = night(5, 2);
const missing = night(6, 3);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ measuredAt: new Date(stored.summary.enddate * 1000) },
] as never);
// The recent night is stored too; it is read again for a re-score.
const fetchMock = installSleepApi(
[...recent.segments, ...stored.segments, ...missing.segments],
[recent.summary, stored.summary, missing.summary],
);
await syncUserSleep("user-1");
expect(stageReads(fetchMock)).toEqual([
[recent.summary.startdate, recent.summary.enddate],
[missing.summary.startdate, missing.summary.enddate],
]);
});
it("re-reads an older stored night Withings changed in the last 48 hours", async () => {
const rescored = night(6, 4);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ measuredAt: new Date(rescored.summary.enddate * 1000) },
] as never);
const fetchMock = installSleepApi(rescored.segments, [
{ ...rescored.summary, modified: nowUnix - 3600 } as FakeSummary,
]);
await syncUserSleep("user-1");
expect(stageReads(fetchMock)).toEqual([
[rescored.summary.startdate, rescored.summary.enddate],
]);
});
it("reads at most a run's budget of missing nights, rotating which", async () => {
const nights = Array.from({ length: 12 }, (_, i) => night(i + 3, i + 1));
const fetchMock = installSleepApi(
nights.flatMap((x) => x.segments),
nights.map((x) => x.summary),
);
await syncUserSleep("user-1");
const first = stageReads(fetchMock);
expect(first).toHaveLength(8);
fetchMock.mockClear();
vi.setSystemTime(new Date(NOW.getTime() + 3600_000));
await syncUserSleep("user-1");
const second = stageReads(fetchMock);
expect(second).toHaveLength(8);
expect(second).not.toEqual(first);
});
it("reads every night of the window on a full sync", async () => {
const nights = Array.from({ length: 12 }, (_, i) => night(i + 3, i + 1));
const fetchMock = installSleepApi(
nights.flatMap((x) => x.segments),
nights.map((x) => x.summary),
);
await syncUserSleep("user-1", { fullSync: true });
expect(stageReads(fetchMock)).toHaveLength(12);
});
it("caps a night longer than a day at the 24 hours one read can return", async () => {
const startdate = nowUnix - 30 * 3600;
const enddate = nowUnix - 2 * 3600; // 28 hours in bed
const fetchMock = installSleepApi(
[{ startdate, enddate: startdate + 3600, state: 1, id: 5 }],
[{ id: 5, startdate, enddate }],
);
await syncUserSleep("user-1");
expect(stageReads(fetchMock)).toEqual([[startdate, startdate + DAY]]);
// A capped read did not see the whole night, so the night is not swept.
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
});