mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
1042 lines
36 KiB
TypeScript
1042 lines
36 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|