Files
HealthLog/tests/integration/withings-sleep-sync.test.ts
T
Marc-André Bombeck 17b8d8d2c2 chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
2026-05-14 23:49:32 +02:00

124 lines
4.2 KiB
TypeScript

/**
* v1.4.25 W17c — Withings Sleep v2 end-to-end integration.
*
* Drives `syncUserSleep` against a mocked Withings response and the
* real Postgres testcontainer. Asserts the contract Migration 0055
* was written for: per-stage rows for the SAME night must all persist
* (one per stage), and a re-sync must update in place rather than
* duplicate them.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { encrypt } from "@/lib/crypto";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-withings-sleep-sync";
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: TEST_USER_ID,
username: "withings-sleep-sync",
email: "withings-sleep-sync@example.test",
},
});
await prisma.withingsConnection.create({
data: {
userId: TEST_USER_ID,
withingsUserId: "wu-1",
accessToken: encrypt("access-token"),
refreshToken: encrypt("refresh-token"),
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
scope: "user.metrics,user.activity",
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function stubWithingsSleep(series: unknown[]) {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({
status: 0,
body: { series },
}),
})),
);
}
describe("syncUserSleep — integration", () => {
it("persists one Measurement row per stage segment for a single night", async () => {
// Sleep session #99: 4 segments — CORE 60m, DEEP 30m, REM 30m, AWAKE 10m.
// All segments share the same parent night but each carries a
// distinct sleepStage, so the W17b/c composite (Migration 0055)
// keeps every row.
const base = 1715000000; // 2024-05-06 13:33:20 UTC (arbitrary)
stubWithingsSleep([
{ startdate: base, enddate: base + 3600, state: 1, id: 99 },
{ startdate: base + 3600, enddate: base + 5400, state: 2, id: 99 },
{ startdate: base + 5400, enddate: base + 7200, state: 3, id: 99 },
{ startdate: base + 7200, enddate: base + 7800, state: 0, id: 99 },
]);
const { syncUserSleep } = await import("@/lib/withings/sync-sleep");
const imported = await syncUserSleep(TEST_USER_ID);
expect(imported).toBe(4);
const prisma = getPrismaClient();
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "SLEEP_DURATION" },
orderBy: { measuredAt: "asc" },
});
expect(rows).toHaveLength(4);
// Every stage represented exactly once.
const stages = rows.map((r) => r.sleepStage).sort();
expect(stages).toEqual(["AWAKE", "CORE", "DEEP", "REM"]);
// Stage minutes — sanity check that seconds→minutes conversion is correct.
const byStage = new Map(rows.map((r) => [r.sleepStage, r.value]));
expect(byStage.get("CORE")).toBe(60);
expect(byStage.get("DEEP")).toBe(30);
expect(byStage.get("REM")).toBe(30);
expect(byStage.get("AWAKE")).toBe(10);
// Every row tagged as WITHINGS source — picker will treat them as
// a single source for the picker pipeline.
expect(rows.every((r) => r.source === "WITHINGS")).toBe(true);
});
it("re-syncing the same night updates rows in place (no duplicates)", async () => {
const base = 1715000000;
// First sync: DEEP 30 minutes.
stubWithingsSleep([
{ startdate: base, enddate: base + 1800, state: 2, id: 100 },
]);
const { syncUserSleep } = await import("@/lib/withings/sync-sleep");
await syncUserSleep(TEST_USER_ID);
// Withings re-aggregates the night and the DEEP segment grew to
// 45 minutes — second sync must UPDATE rather than INSERT.
stubWithingsSleep([
{ startdate: base, enddate: base + 2700, state: 2, id: 100 },
]);
await syncUserSleep(TEST_USER_ID);
const prisma = getPrismaClient();
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "SLEEP_DURATION" },
});
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe(45);
expect(rows[0].sleepStage).toBe("DEEP");
});
});