diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts index ea945c843..96847511c 100644 --- a/src/lib/withings/__tests__/sync-sleep.test.ts +++ b/src/lib/withings/__tests__/sync-sleep.test.ts @@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({ prisma: { measurement: { findFirst: vi.fn(), + findMany: vi.fn(), create: vi.fn(), update: vi.fn(), upsert: vi.fn(), @@ -105,18 +106,71 @@ interface FakeSegment { id?: number; } -function installFetchMock(segments: FakeSegment[]) { - const fetchMock = vi.fn(async () => ({ - status: 200, - json: async () => ({ - status: 0, - body: { series: segments }, - }), - })); +type FakeSummary = { + id?: number; + startdate: number; + enddate: number; + modified?: number; + data?: Record; +}; + +/** The night list Withings would derive from these segments, one per session. */ +function nightsOf(segments: FakeSegment[]): FakeSummary[] { + const bySession = new Map(); + 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) => @@ -144,6 +198,8 @@ beforeEach(() => { 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(() => { @@ -158,21 +214,9 @@ afterEach(() => { */ function installSegmentThenSummaryFetch( segments: FakeSegment[], - summaries: Array<{ - id?: number; - startdate: number; - enddate: number; - data?: Record; - }>, + summaries: FakeSummary[], ) { - let call = 0; - const fetchMock = vi.fn(async () => { - const body = call === 0 ? { series: segments } : { series: summaries }; - call++; - return { status: 200, json: async () => ({ status: 0, body }) }; - }); - vi.stubGlobal("fetch", fetchMock); - return fetchMock; + return installSleepApi(segments, summaries); } describe("mapWithingsSleepSummary", () => { @@ -559,44 +603,50 @@ describe("syncUserSleep — segment writes + idempotency", () => { expect(byType.get("OXYGEN_SATURATION")?.value).toBe(95); }); - it("keeps stage segments when the summary fetch fails (v1.18.10 P0)", async () => { - let call = 0; - vi.stubGlobal( - "fetch", - vi.fn(async () => { - const isSummary = call > 0; - call++; - return { - status: 200, - json: async () => - isSummary - ? { status: 293 } // summary errors - : { - status: 0, - body: { - series: [ - { - startdate: 1715000000, - enddate: 1715003600, - state: 2, - id: 1, - }, - ], - }, + 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.mocked(prisma.measurement.findFirst).mockResolvedValue(null); - vi.mocked(prisma.measurement.create).mockResolvedValue({} as never); + }, + }; + }); + vi.stubGlobal("fetch", fetchMock); const imported = await syncUserSleep("user-1"); - // The one stage segment is still written; the summary failure is swallowed. + + // 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); - expect(reconcileMock).toHaveBeenCalledTimes(1); - expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", { - leg: "sleep", - }); + 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 () => { @@ -848,3 +898,144 @@ describe("syncUserSleep — Withings API plan (status 277)", () => { }); }); }); + +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) { + 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(); + }); +}); diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 41dd7f5b1..d2e6a0855 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -122,6 +122,8 @@ export interface WithingsSleepSummary { id?: number; startdate: number; enddate: number; + /** When Withings last changed the night (unix seconds), e.g. a re-score. */ + modified?: number; data?: { hr_average?: number | null; rr_average?: number | null; @@ -385,56 +387,145 @@ export async function syncUserSleep( return 0; } - void opts.fullSync; const now = new Date(); const start = new Date( now.getTime() - SLEEP_BACKFILL_DAYS * 24 * 60 * 60 * 1000, ); - const startUnix = Math.floor(start.getTime() / 1000); const endUnix = Math.floor(now.getTime() / 1000); - let segments: WithingsSleepSegment[]; + // The nights come first: `action=getsummary` lists every night of the + // window (and carries its vitals), and the stage timeline is then read per + // night. `action=get` answers only the first 24 hours after its + // `startdate` ("If your input startdate and enddate are separated by more + // than 24h, only the first 24h after startdate will be returned", Sleep v2 + // Get); asked for the whole window it returned the night a month back and + // nothing since, so every night's stages arrived when the sliding window + // reached it, weeks late. + // + // Withings API plans (2026-10-12): the sleep score, the overnight HRV and + // the respiration rate are Advanced Biomarkers, and one of them in + // `data_fields` has the whole summary refused with 277. Ask only for what + // the plan allows, remember what it refused, and re-probe weekly. + let summaries: WithingsSleepSummary[] = []; + let summaryFailure: unknown = null; + let nightsKnown = false; try { - segments = await fetchWithingsSleep( - tokenInfo.accessToken, - startUnix, - endUnix, - ); - } catch (err) { - // The stage timeline is a free-plan biomarker, so a plan refusal here is - // not expected. If Withings ever moves it, the leg reads nothing rather - // than failing: a 277 must not climb the strike ladder and park the - // measures, activity and ECG legs along with it. - if (isPlanGatedError(err)) { - annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } }); - await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP }); - return 0; - } - // v1.4.43 — typed-classification path (see sync-activity.ts - // for the full rationale). The thrown `WithingsApiError` carries - // the classification verdict; `recordWithingsSyncFailure` routes - // it through `classifyError`, which falls back to the legacy regex - // for un-prototyped errors (pg-boss JSON round-trip). - // - // BL-P3-2 defence-in-depth — symmetric to sync-activity. A 403 on - // this scope-gated endpoint always means scope-missing or - // token-revoked; force `reauth_required` so the 3-strike alert - // still pages on the unexpected case. - const withingsStatus = - err instanceof WithingsApiError ? err.withingsStatus : undefined; - if (withingsStatus === 403) { - await recordSyncFailure({ - userId, - integration: "withings", - kind: "reauth_required", - message: err instanceof Error ? err.message : String(err), - errorCode: "403", - leg: WITHINGS_LEG_SLEEP, + const planRow = await prisma.withingsConnection.findUnique({ + where: { userId }, + select: { apiPlanState: true }, + }); + const sleepPlan = parseWithingsApiPlanState( + planRow?.apiPlanState, + ).sleepSummary; + const reprobed = isPlanReprobeDue(sleepPlan, now); + const skip = planSkipFor(sleepPlan, now); + const plan = await requestWithinPlan({ + keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)), + knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS, + fetch: (fields) => + fetchWithingsSleepSummary( + tokenInfo.accessToken, + ymdUtc(start), + ymdUtc(now), + fields, + ), + }); + summaries = plan.results; + nightsKnown = plan.acceptedAny; + if ( + shouldPersistPlanLeg({ + gated: plan.gated, + reprobed, + acceptedAny: plan.acceptedAny, + }) + ) { + await persistWithingsPlanLeg(userId, "sleepSummary", { + unavailable: [...skip, ...plan.unavailable], + checkedAt: planCheckedAtFor(sleepPlan, reprobed, now), }); + } + } catch (err) { + // Booked as this leg's failure once the stages are written: the summary + // carries the vitals and names the nights, and a run without it is not + // a healthy one. It used to be a warning, and the leg read green. + summaryFailure = err; + getEvent()?.addWarning( + `withings sleep: summary fetch failed for ${userId}: ${err}`, + ); + } + + // Without the night list (the summary failed, or the plan refused every + // field it was asked for) the last 24 hours are read as one slice, so last + // night still lands. Such a slice can cut a night, so it is never swept. + const windows: StageWindow[] = nightsKnown + ? await stageWindowsToFetch(userId, summaries, now, { + all: opts.fullSync === true, + }) + : [ + { + startdate: endUnix - DAY_SECONDS, + enddate: endUnix, + wholeNight: false, + }, + ]; + + const segments: WithingsSleepSegment[] = []; + const wholeNightSessions = new Set(); + const seenSegments = new Set(); + for (const window of windows) { + let series: WithingsSleepSegment[]; + try { + series = await fetchWithingsSleep( + tokenInfo.accessToken, + window.startdate, + window.enddate, + ); + } catch (err) { + // The stage timeline is a free-plan biomarker, so a plan refusal here + // is not expected. If Withings ever moves it, the leg reads no stages + // rather than failing: a 277 must not climb the strike ladder and park + // the measures, activity and ECG legs along with it. + if (isPlanGatedError(err)) { + annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } }); + break; + } + // v1.4.43 — typed-classification path (see sync-activity.ts + // for the full rationale). The thrown `WithingsApiError` carries + // the classification verdict; `recordWithingsSyncFailure` routes + // it through `classifyError`, which falls back to the legacy regex + // for un-prototyped errors (pg-boss JSON round-trip). + // + // BL-P3-2 defence-in-depth — symmetric to sync-activity. A 403 on + // this scope-gated endpoint always means scope-missing or + // token-revoked; force `reauth_required` so the 3-strike alert + // still pages on the unexpected case. + const withingsStatus = + err instanceof WithingsApiError ? err.withingsStatus : undefined; + if (withingsStatus === 403) { + await recordSyncFailure({ + userId, + integration: "withings", + kind: "reauth_required", + message: err instanceof Error ? err.message : String(err), + errorCode: "403", + leg: WITHINGS_LEG_SLEEP, + }); + throw err; + } + await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_SLEEP); throw err; } - await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_SLEEP); - throw err; + for (const segment of series) { + // Two windows can return the same segment (a night longer than its + // 24-hour slice, a nap inside a night); it is written once. + const key = `${segment.id ?? "no-id"}:${segment.startdate}`; + if (seenSegments.has(key)) continue; + seenSegments.add(key); + segments.push(segment); + if (window.wholeNight && typeof segment.id === "number") { + wholeNightSessions.add(segment.id); + } + } } let imported = 0; @@ -528,63 +619,19 @@ export async function syncUserSleep( // running-index row for re-fetched sessions. Sessions absent from this fetch // are never touched; a sweep failure never fails the sync (the session stays // inside the rolling 30-day window, so the hourly cron retries it). - const sweeps: SleepSegmentSweep[] = [...freshBySession].map( - ([sessionId, keepIds]) => ({ + const sweeps: SleepSegmentSweep[] = [...freshBySession] + // Only a session read across its whole night is swept: a 24-hour slice + // can cut one, and sweeping it would retire the part outside the slice. + .filter(([sessionId]) => wholeNightSessions.has(sessionId)) + .map(([sessionId, keepIds]) => ({ prefix: `withings:sleep:${userId}:${sessionId}:`, keepIds, - }), - ); + })); await sweepStaleSleepSegments(userId, "WITHINGS", sweeps); // v1.18.10 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV - // / avg SpO2 / sleep score) from the per-night summary. Fetch failure stays - // isolated from the stage writes; database failures are hard sync failures. - // - // Withings API plans (2026-10-12): the sleep score, the overnight HRV and the - // respiration rate are Advanced Biomarkers, and one of them in `data_fields` - // has the whole summary refused with 277. Ask only for what the plan allows, - // remember what it refused, and re-probe weekly. - let summaries: WithingsSleepSummary[] = []; - try { - const planRow = await prisma.withingsConnection.findUnique({ - where: { userId }, - select: { apiPlanState: true }, - }); - const sleepPlan = parseWithingsApiPlanState( - planRow?.apiPlanState, - ).sleepSummary; - const reprobed = isPlanReprobeDue(sleepPlan, now); - const skip = planSkipFor(sleepPlan, now); - const plan = await requestWithinPlan({ - keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)), - knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS, - fetch: (fields) => - fetchWithingsSleepSummary( - tokenInfo.accessToken, - ymdUtc(start), - ymdUtc(now), - fields, - ), - }); - summaries = plan.results; - if ( - shouldPersistPlanLeg({ - gated: plan.gated, - reprobed, - acceptedAny: plan.acceptedAny, - }) - ) { - await persistWithingsPlanLeg(userId, "sleepSummary", { - unavailable: [...skip, ...plan.unavailable], - checkedAt: planCheckedAtFor(sleepPlan, reprobed, now), - }); - } - } catch (err) { - getEvent()?.addWarning( - `withings sleep: summary fetch failed for ${userId}: ${err}`, - ); - } - + // / avg SpO2 / sleep score) from the per-night summary read above. Database + // failures are hard sync failures. await prisma .$transaction( async (tx) => { @@ -656,6 +703,103 @@ export async function syncUserSleep( () => {}, ); + if (summaryFailure !== null) { + await recordWithingsSyncFailure(userId, summaryFailure, WITHINGS_LEG_SLEEP); + return imported; + } await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP }); return imported; } + +const DAY_SECONDS = 24 * 60 * 60; + +/** Nights that ended this recently are read again on every run (re-scores). */ +const RECENT_NIGHT_SECONDS = 2 * DAY_SECONDS; + +/** + * Stage reads per run beyond the recent nights. A new connection, or a + * window the sleep-timeline backfill just cleared, fills in over a few runs + * instead of firing thirty requests at once; a full sync reads every night. + */ +export const SLEEP_STAGE_NIGHTS_PER_RUN = 8; + +/** One `action=get` read: a night's own span, at most 24 hours. */ +interface StageWindow { + startdate: number; + enddate: number; + /** The read covers the whole night, so its session may be swept. */ + wholeNight: boolean; +} + +/** + * The nights whose stage timeline this run reads, from the summary's night + * list: every night that ended, or that Withings changed (`modified`), in the + * last 48 hours, since Withings re-scores a night after the fact; then nights + * that hold no Withings stage row yet, + * up to `SLEEP_STAGE_NIGHTS_PER_RUN`. The missing nights rotate by the hour, + * so a night that never carries stages (a tracker without them) cannot keep + * the ones behind it waiting. + */ +async function stageWindowsToFetch( + userId: string, + summaries: readonly WithingsSleepSummary[], + now: Date, + opts: { all: boolean }, +): Promise { + const nights = new Map< + string, + { startdate: number; enddate: number; changed: number } + >(); + for (const s of summaries) { + if (!Number.isFinite(s.startdate) || !Number.isFinite(s.enddate)) continue; + if (s.enddate <= s.startdate) continue; + const modified = Number.isFinite(s.modified) ? s.modified! : 0; + const key = `${s.startdate}:${s.enddate}`; + nights.set(key, { + startdate: s.startdate, + enddate: s.enddate, + changed: Math.max(s.enddate, modified, nights.get(key)?.changed ?? 0), + }); + } + const all = [...nights.values()].sort((x, y) => y.enddate - x.enddate); + const toWindow = (n: { + startdate: number; + enddate: number; + }): StageWindow => ({ + startdate: n.startdate, + enddate: Math.min(n.enddate, n.startdate + DAY_SECONDS), + wholeNight: n.enddate - n.startdate <= DAY_SECONDS, + }); + if (opts.all || all.length === 0) return all.map(toWindow); + + const nowUnix = Math.floor(now.getTime() / 1000); + // Recent: ended, or was changed by Withings, within the last 48 hours. + const recent = all.filter((n) => n.changed >= nowUnix - RECENT_NIGHT_SECONDS); + const older = all.filter((n) => n.changed < nowUnix - RECENT_NIGHT_SECONDS); + if (older.length === 0) return recent.map(toWindow); + + // Segments are stamped at their END, so a night holds a stage row when one + // ends inside (startdate, enddate]. One read over the span of the older + // nights, on the `(user_id, type, measured_at)` index. + const first = Math.min(...older.map((n) => n.startdate)); + const last = Math.max(...older.map((n) => n.enddate)); + const stored = await prisma.measurement.findMany({ + where: { + userId, + source: "WITHINGS", + type: SLEEP_TYPE, + deletedAt: null, + measuredAt: { gt: new Date(first * 1000), lte: new Date(last * 1000) }, + }, + select: { measuredAt: true }, + }); + const endsAt = stored.map((row) => row.measuredAt.getTime() / 1000); + const missing = older + .filter((n) => !endsAt.some((t) => t > n.startdate && t <= n.enddate)) + .sort((x, y) => x.enddate - y.enddate); + const budget = Math.max(0, SLEEP_STAGE_NIGHTS_PER_RUN - recent.length); + if (missing.length <= budget) return [...recent, ...missing].map(toWindow); + const offset = Math.floor(nowUnix / 3600) % missing.length; + const rotated = [...missing.slice(offset), ...missing.slice(0, offset)]; + return [...recent, ...rotated.slice(0, budget)].map(toWindow); +} diff --git a/tests/integration/withings-sleep-sync.test.ts b/tests/integration/withings-sleep-sync.test.ts index 29134c408..de98eef94 100644 --- a/tests/integration/withings-sleep-sync.test.ts +++ b/tests/integration/withings-sleep-sync.test.ts @@ -51,26 +51,70 @@ afterEach(() => { vi.restoreAllMocks(); }); -function stubWithingsSleep(series: unknown[]) { +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 () => ({ - status: 200, - json: async () => ({ - status: 0, - body: { series }, - }), - })), + 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. - 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 }, @@ -106,7 +150,6 @@ describe("syncUserSleep — integration", () => { }); 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 },