From 74aa41519f9e22740b52f4cf1ee81ac93b839c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 18:52:47 +0200 Subject: [PATCH] test(sleep): integration coverage for the list collapse and analytics reconcile Lock the SLEEP_DURATION list collapse end-to-end (one row per night, dayKey drill-down to stage segments) and the analytics breakdown reconciliation (a midnight-spanning dual-source night stays one bucket, canonical source only, no double-count). --- .../analytics-sleep-stages.test.ts | 59 +++++++ .../measurements-sleep-list.test.ts | 157 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 tests/integration/measurements-sleep-list.test.ts diff --git a/tests/integration/analytics-sleep-stages.test.ts b/tests/integration/analytics-sleep-stages.test.ts index 64800c723..983d06b6f 100644 --- a/tests/integration/analytics-sleep-stages.test.ts +++ b/tests/integration/analytics-sleep-stages.test.ts @@ -68,6 +68,7 @@ interface AnalyticsEnvelope { nights: number; totalMinutes: number; stages: Record; + perNight: Array<{ dayKey: string; stages: Record }>; } | null; }; } @@ -191,4 +192,62 @@ describe("GET /api/analytics — sleep-stage aggregation", () => { expect(envelope.data.summaries.SLEEP_DURATION.count).toBe(1); expect(envelope.data.summaries.SLEEP_DURATION.latest).toBe(420); }); + + // v1.11.5 — the breakdown is now reconciled onto reconstructSleepNights, + // so a midnight-spanning night stays ONE perNight bucket and a dual-source + // night collapses to the canonical source (no double-count). + it("keeps a midnight-spanning, dual-source night as one reconciled bucket", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("sleep-reconcile-user"); + + // Contiguous overnight: 22:30 → 06:15 local (Berlin, UTC+2 in June), + // stage ends straddling UTC midnight. WHOOP + Apple Health both report + // it; WHOOP wins the default ladder. + const seed = async ( + iso: string, + stage: "CORE" | "DEEP" | "REM", + min: number, + source: "WHOOP" | "APPLE_HEALTH", + ) => { + await prisma.measurement.create({ + data: { + userId: user.id, + type: "SLEEP_DURATION", + value: min, + unit: "minutes", + source, + measuredAt: new Date(iso), + externalId: `uuid-${source}-${stage}-${iso}`, + sleepStage: stage, + }, + }); + }; + // WHOOP — the canonical source. CORE 60 + DEEP 90 + REM 120 + CORE 195. + await seed("2026-06-03T21:30:00.000Z", "CORE", 60, "WHOOP"); + await seed("2026-06-03T23:00:00.000Z", "DEEP", 90, "WHOOP"); + await seed("2026-06-04T01:00:00.000Z", "REM", 120, "WHOOP"); + await seed("2026-06-04T04:15:00.000Z", "CORE", 195, "WHOOP"); + // Apple Health parallel rows for the SAME night — must be dropped. + await seed("2026-06-03T21:35:00.000Z", "CORE", 55, "APPLE_HEALTH"); + await seed("2026-06-04T01:05:00.000Z", "REM", 110, "APPLE_HEALTH"); + + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + expect(response.status).toBe(200); + const envelope = (await response.json()) as AnalyticsEnvelope; + + const sleepStages = envelope.data.sleepStages; + expect(sleepStages).not.toBeNull(); + // ONE night, not two (no midnight split). + expect(sleepStages!.nights).toBe(1); + expect(sleepStages!.perNight).toHaveLength(1); + // WHOOP only — Apple Health's parallel rows dropped (no double-count). + expect(sleepStages!.stages.CORE).toBe(255); // 60 + 195 + expect(sleepStages!.stages.DEEP).toBe(90); + expect(sleepStages!.stages.REM).toBe(120); + // Total = the WHOOP night only (255 + 90 + 120 = 465), not a blend. + expect(sleepStages!.totalMinutes).toBe(465); + }); }); diff --git a/tests/integration/measurements-sleep-list.test.ts b/tests/integration/measurements-sleep-list.test.ts new file mode 100644 index 000000000..15fff4936 --- /dev/null +++ b/tests/integration/measurements-sleep-list.test.ts @@ -0,0 +1,157 @@ +/** + * v1.11.5 — SLEEP_DURATION list collapse. + * + * `GET /api/measurements?type=SLEEP_DURATION` is stored one row per stage + * per night. The list route now collapses those raw stage rows into one + * synthetic row per night (TIME ASLEEP), with a `dayKey` drill-down to the + * night's stage segments. This locks the leak closure end-to-end. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +interface ListEnvelope { + data: { + measurements: Array<{ + id: string; + type: string; + value: number; + unit: string; + dayKey?: string; + sampleCount?: number; + sleepStage?: string | null; + napCount?: number; + awakenings?: number; + }>; + meta: { total: number; groupBy?: string }; + }; +} + +async function seedSession(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username, email: `${username}@example.test`, role: "USER" }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +async function callList(query: string): Promise { + const { GET } = await import("@/app/api/measurements/route"); + return GET( + new NextRequest(`http://localhost/api/measurements?${query}`), + ); +} + +describe("GET /api/measurements?type=SLEEP_DURATION", () => { + it("collapses raw stage rows into one row per night", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("sleep-list-user"); + + // Recent night so the trailing-year window includes it. Contiguous + // stage rows ending this morning (Berlin default tz). + const wakeBase = new Date(); + wakeBase.setUTCHours(4, 0, 0, 0); + const seed = async (stage: "CORE" | "DEEP" | "REM", min: number, offsetMin: number) => + prisma.measurement.create({ + data: { + userId: user.id, + type: "SLEEP_DURATION", + value: min, + unit: "minutes", + source: "APPLE_HEALTH", + measuredAt: new Date(wakeBase.getTime() - offsetMin * 60_000), + externalId: `uuid-${stage}`, + sleepStage: stage, + }, + }); + // CORE 240 (ends -180), DEEP 120 (ends -60), REM 60 (ends 0). + await seed("CORE", 240, 180); + await seed("DEEP", 120, 60); + await seed("REM", 60, 0); + + const res = await callList("type=SLEEP_DURATION"); + expect(res.status).toBe(200); + const body = (await res.json()) as ListEnvelope; + // ONE per-night row, not three stage rows. + expect(body.data.measurements).toHaveLength(1); + const row = body.data.measurements[0]; + // value = TIME ASLEEP = 240 + 120 + 60 = 420 min. + expect(row.value).toBe(420); + expect(row.dayKey).toBeDefined(); + expect(body.data.meta.groupBy).toBe("night"); + }); + + it("drills down to the night's stage segments via dayKey", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("sleep-drill-user"); + + const wakeBase = new Date(); + wakeBase.setUTCHours(4, 0, 0, 0); + const seed = async (stage: "CORE" | "DEEP" | "REM", min: number, offsetMin: number) => + prisma.measurement.create({ + data: { + userId: user.id, + type: "SLEEP_DURATION", + value: min, + unit: "minutes", + source: "APPLE_HEALTH", + measuredAt: new Date(wakeBase.getTime() - offsetMin * 60_000), + externalId: `uuid-${stage}`, + sleepStage: stage, + }, + }); + await seed("CORE", 240, 180); + await seed("DEEP", 120, 60); + await seed("REM", 60, 0); + + // First get the per-night row to learn its dayKey. + const nightRes = await callList("type=SLEEP_DURATION"); + const nightBody = (await nightRes.json()) as ListEnvelope; + const dayKey = nightBody.data.measurements[0].dayKey!; + + const res = await callList(`type=SLEEP_DURATION&dayKey=${dayKey}`); + expect(res.status).toBe(200); + const body = (await res.json()) as ListEnvelope; + // The drill-down returns the three stage segments. + expect(body.data.measurements).toHaveLength(3); + const stages = body.data.measurements.map((m) => m.sleepStage).sort(); + expect(stages).toEqual(["CORE", "DEEP", "REM"]); + }); +});