/** * The sleep list pages its nights in SQL (`sleepNightPage`) and fills them * from the JS reconstruction (`reconstructSleepSessions`). The two cut the * same stage rows into sessions and name each session's night by the same * rule, or the list drops, doubles or mislabels nights. This suite runs both * over one fixture set of awkward shapes and holds them to the same nights, * then holds the list, the night view and the series to one answer per day. */ 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(); }); type Stage = "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP" | null; interface Seg { /** Segment end, minutes after the fixture day's UTC midnight. */ end: number; minutes: number; stage: Stage; source?: "APPLE_HEALTH" | "WHOOP" | "WITHINGS"; deviceType?: string | null; } const H = 60; /** A run of contiguous segments of `step` minutes cycling through stages. */ function run( start: number, end: number, step: number, stages: Stage[], extra: Partial = {}, ): Seg[] { const out: Seg[] = []; let i = 0; for (let t = start + step; t <= end; t += step) { out.push({ end: t, minutes: step, stage: stages[i % stages.length], ...extra, }); i += 1; } return out; } const NIGHT: Stage[] = ["CORE", "DEEP", "CORE", "REM", "AWAKE"]; /** * Ten shapes, one per day modulo ten, minutes relative to the day's UTC * midnight (negative = the evening before). */ function shape(i: number): Seg[] { switch (i % 10) { case 0: // A dense watch night in five-minute segments. return run(-1 * H, 7 * H, 5, NIGHT, { deviceType: "watch" }); case 1: // The watch stops before midnight, the phone's in-bed runs past it. return [ ...run(-6 * H, -10, 10, NIGHT, { deviceType: "watch" }), { end: 20, minutes: 6 * H + 20, stage: "IN_BED", deviceType: "phone" }, ]; case 2: // An afternoon nap and no night. return run(13 * H, 14 * H, 15, ["CORE", "REM"]); case 3: // A night and a nap. return [ ...run(-2 * H, 6 * H, 30, NIGHT), ...run(14 * H, 14 * H + 40, 20, ["CORE"]), ]; case 4: // A split night, three and a half hours apart: two sessions. return [ ...run(-2 * H, 1 * H, 30, NIGHT), ...run(4 * H + 30, 7 * H, 30, NIGHT), ]; case 5: // Exactly the session gap apart: one session. return [ ...run(-2 * H, 1 * H, 30, NIGHT), ...run(4 * H, 7 * H, 30, NIGHT), ]; case 6: // One long stage block. return [ { end: 3 * H, minutes: 4 * H, stage: "CORE" }, { end: 5 * H, minutes: 2 * H, stage: "REM" }, ]; case 7: // A bare aggregate with its granular twin, and a zero-minute granular // row from a second writer beside a bare night. return [ { end: 6 * H, minutes: 7 * H, stage: "ASLEEP" }, ...run(-1 * H, 6 * H, 60, ["CORE", "DEEP", "REM"]), { end: 6 * H, minutes: 0, stage: "REM", source: "WITHINGS" }, { end: 6 * H, minutes: 6 * H, stage: "ASLEEP", source: "WITHINGS" }, ]; case 8: // An in-bed-only evening and a stage-less night. return [ { end: -8 * H, minutes: 40, stage: "IN_BED" }, { end: -8 * H + 30, minutes: 20, stage: "AWAKE" }, { end: 6 * H, minutes: 6 * H, stage: null }, ]; default: // A summary-shaped writer (every stage on one instant) beside a // partial timed night. return [ { end: 7 * H, minutes: 200, stage: "CORE", source: "WHOOP" }, { end: 7 * H, minutes: 90, stage: "DEEP", source: "WHOOP" }, { end: 7 * H, minutes: 80, stage: "REM", source: "WHOOP" }, ...run(2 * H, 7 * H, 30, NIGHT), ]; } } const BASE = Date.UTC(2024, 2, 1); // three years back, across two DST turns const DAYS = 120; async function seed(timezone: string) { const prisma = getPrismaClient(); const user = await prisma.user.create({ data: { username: `parity-${timezone.replace("/", "-")}`.toLowerCase(), email: `parity-${timezone.length}@example.test`, role: "USER", timezone, }, }); const session = await prisma.session.create({ data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) }, }); cookieJar.set("healthlog_session", session.id); const rows = []; for (let d = 0; d < DAYS; d++) { const midnight = BASE + d * 86_400_000; for (const [k, seg] of shape(d).entries()) { rows.push({ userId: user.id, type: "SLEEP_DURATION" as const, value: seg.minutes, unit: "minutes", source: seg.source ?? ("APPLE_HEALTH" as const), deviceType: seg.deviceType ?? null, measuredAt: new Date(midnight + seg.end * 60_000), externalId: `p-${d}-${k}`, sleepStage: seg.stage, }); } } await prisma.measurement.createMany({ data: rows }); return user; } interface ListRow { dayKey: string; value: number; napCount: number; napAsleepMinutes: number; } async function list(query: string) { const { GET } = await import("@/app/api/measurements/route"); const res = await GET( new NextRequest(`http://localhost/api/measurements?${query}`), ); expect(res.status).toBe(200); return (await res.json()) as { data: { measurements: ListRow[]; meta: { total: number } }; }; } async function nightView(date: string) { const { GET } = await import("@/app/api/sleep/night/route"); const res = await GET( new NextRequest(`http://localhost/api/sleep/night?date=${date}`), ); expect(res.status).toBe(200); return (await res.json()) as { data: { night: string | null; main: { asleepMinutes: number } | null; naps: Array<{ asleepMinutes: number }>; }; }; } describe("sleep nights: SQL page and JS reconstruction agree", () => { for (const tz of ["UTC", "Europe/Berlin"]) { it(`names the same nights over every shape (${tz})`, async () => { const user = await seed(tz); const { sleepNightPage, readSleepRows } = await import("@/lib/measurements/list/sleep-nights"); const { reconstructSleepSessions } = await import("@/lib/analytics/sleep-night"); const page = await sleepNightPage(user.id, tz, { sortDir: "asc", limit: 10_000, offset: 0, }); const rows = await readSleepRows( user.id, new Date(BASE - 2 * 86_400_000), new Date(BASE + (DAYS + 2) * 86_400_000), ); const scorable = reconstructSleepSessions(rows, tz, null).filter( (s) => s.asleepMinutes > 0, ); const jsNights = [...new Set(scorable.map((s) => s.night))].sort(); expect(page.nights.map((n) => n.night)).toEqual(jsNights); expect(page.total).toBe(jsNights.length); // Every reconstructed session lies inside its night's SQL span. const span = new Map(page.nights.map((n) => [n.night, n])); for (const s of scorable) { const n = span.get(s.night)!; expect(s.start.getTime()).toBeGreaterThanOrEqual( n.spanStart.getTime() - 1000, ); expect(s.end.getTime()).toBeLessThanOrEqual(n.spanEnd.getTime() + 1000); } }, 240_000); } it("gives every page night a row the night view answers for the same day", async () => { await seed("Europe/Berlin"); const first = await list("type=SLEEP_DURATION&limit=7&sortDir=asc"); const total = first.data.meta.total; const rows: ListRow[] = []; for (let offset = 0; offset < total; offset += 7) { const page = await list( `type=SLEEP_DURATION&limit=7&offset=${offset}&sortDir=asc`, ); // No short page before the last one. expect(page.data.measurements).toHaveLength(Math.min(7, total - offset)); rows.push(...page.data.measurements); } expect(rows).toHaveLength(total); let napOnly = 0; for (const row of rows) { const view = await nightView(row.dayKey); expect(view.data.night).toBe(row.dayKey); expect(view.data.main?.asleepMinutes ?? 0).toBe(row.value); expect(view.data.naps).toHaveLength(row.napCount); if (!view.data.main) { napOnly += 1; expect(row.napCount).toBeGreaterThan(0); } } // The afternoon-nap days have no main night and still have their row. expect(napOnly).toBe(DAYS / 10); }, 480_000); it("reaches the oldest night from the series' All range", async () => { await seed("Europe/Berlin"); const { GET } = await import("@/app/api/measurements/series/route"); const res = await GET( new NextRequest( "http://localhost/api/measurements/series?kind=sleep&days=3650", ), ); expect(res.status).toBe(200); const body = (await res.json()) as { data: { points: Array<{ id: string }> }; }; const oldest = (await list("type=SLEEP_DURATION&limit=1&sortDir=asc")).data .measurements[0].dayKey; // Years old, past any one-year cut. expect(body.data.points[0].id).toBe(`sleep:${oldest}`); expect(oldest < "2024-03-03").toBe(true); }, 240_000); });