mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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.
176 lines
5.9 KiB
TypeScript
176 lines
5.9 KiB
TypeScript
/**
|
|
* v1.4.25 — 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";
|
|
|
|
// Crypto reads `ENCRYPTION_KEY` lazily on first encrypt(). Under the
|
|
// previous `isolate: false` suite the env from any sibling that seeded
|
|
// the key leaked into this file; per-file isolation (v1.4.37 W-CI)
|
|
// reveals that this spec never seeded its own key. Pin a deterministic
|
|
// 32-byte test key before any `@/lib/crypto` import so the spec stays
|
|
// self-contained.
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
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();
|
|
});
|
|
|
|
type Segment = {
|
|
startdate: number;
|
|
enddate: number;
|
|
state: number;
|
|
id: number;
|
|
};
|
|
|
|
/**
|
|
* Answer Sleep v2 the way Withings does: `getsummary` lists the nights,
|
|
* `get` returns the segments inside the span asked for, at most its first
|
|
* 24 hours.
|
|
*/
|
|
function stubWithingsSleep(series: Segment[]) {
|
|
const nights = new Map<
|
|
number,
|
|
{ id: number; startdate: number; enddate: number }
|
|
>();
|
|
for (const seg of series) {
|
|
const night = nights.get(seg.id);
|
|
nights.set(seg.id, {
|
|
id: seg.id,
|
|
startdate: Math.min(night?.startdate ?? seg.startdate, seg.startdate),
|
|
enddate: Math.max(night?.enddate ?? seg.enddate, seg.enddate),
|
|
});
|
|
}
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
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: [...nights.values()] },
|
|
}),
|
|
};
|
|
}
|
|
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: series.filter(
|
|
(seg) => seg.startdate >= from && seg.enddate <= to,
|
|
),
|
|
},
|
|
}),
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Last night: it started ten hours ago, so a run reads it again for a re-score. */
|
|
const base = Math.floor(Date.now() / 1000) - 10 * 3600;
|
|
|
|
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.
|
|
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 () => {
|
|
// 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");
|
|
});
|
|
});
|