Files
HealthLog/tests/integration/measurements-sleep-list.test.ts
T
Marc-André Bombeck 63bbf0931c fix(sleep): one day total for every sleep reader, and the latest night is a night
A day's sleep is the main night plus its naps (daySleepMinutes). The
series, the sleep score and the composition already read it that way; the
sleep list, the CSV export and the correlation sleep channel read the main
night alone, so a day of naps listed as 0 h and an average over the list
counted it as a night without sleep. The list now carries the day total in
value and the main night alone in the new mainAsleepMinutes field; the nap
caption reads as included.

GET /api/sleep/night without a date answered the latest day holding any
sleep, so a nap after the last night left the hypnogram with no night to
draw. It now walks back to the latest night with a main sleep; nap days
stay reachable by date.

The SQL night page bounded a night by its asleep sessions only, so an
in-bed-only session of the same night fell outside a chunked read that the
one-night read counted. The span now covers every session of the night.
2026-10-20 09:28:40 +02:00

355 lines
12 KiB
TypeScript

/**
* 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;
mainAsleepMinutes?: number | null;
awakenings?: number;
}>;
meta: { total: number; groupBy?: string; windowFrom?: 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({
// An hour, not a minute: walking every page of a long history can
// outlast a minute on a loaded runner, and an expired session reads as
// a 401 halfway through.
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
async function callList(query: string): Promise<Response> {
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"]);
});
it("pages over every night of a long history in SQL, with no year cut", async () => {
const prisma = getPrismaClient();
const user = await seedSession("sleep-paging-user");
const day = 86_400_000;
const morning = new Date();
morning.setUTCHours(4, 0, 0, 0);
const NIGHTS = 730;
const dayKeyOf = (d: Date) => d.toISOString().slice(0, 10);
// One night of a single CORE stage every day for two years. Every
// seventh day also carries an afternoon nap, which is not a night of its
// own, and every eleventh an in-bed-only evening (more than the session
// gap from both the nap and the night), which holds no sleep and must
// not count as a night either.
const rows: Array<{
userId: string;
type: "SLEEP_DURATION";
value: number;
unit: string;
source: "APPLE_HEALTH";
measuredAt: Date;
externalId: string;
sleepStage: "CORE" | "IN_BED";
}> = [];
for (let i = 0; i < NIGHTS; i++) {
const wake = new Date(morning.getTime() - i * day);
const base = {
userId: user.id,
type: "SLEEP_DURATION" as const,
unit: "minutes",
source: "APPLE_HEALTH" as const,
};
rows.push({
...base,
value: 420,
measuredAt: wake,
externalId: `uuid-night-${i}`,
sleepStage: "CORE",
});
if (i % 7 === 0) {
rows.push({
...base,
value: 30,
measuredAt: new Date(wake.getTime() + 10 * 3_600_000),
externalId: `uuid-nap-${i}`,
sleepStage: "CORE",
});
}
if (i % 11 === 5) {
rows.push({
...base,
value: 20,
measuredAt: new Date(wake.getTime() - 10.5 * 3_600_000),
externalId: `uuid-inbed-${i}`,
sleepStage: "IN_BED",
});
}
}
await prisma.measurement.createMany({ data: rows });
const spy = vi.spyOn(prisma.measurement, "findMany");
const first = await callList("type=SLEEP_DURATION&limit=25");
expect(first.status).toBe(200);
const firstBody = (await first.json()) as ListEnvelope;
expect(firstBody.data.meta.total).toBe(NIGHTS);
expect(firstBody.data.meta.windowFrom).toBeUndefined();
expect(firstBody.data.measurements).toHaveLength(25);
expect(firstBody.data.measurements[0].dayKey).toBe(dayKeyOf(morning));
// The day's sleep counts the nap; the main night rides beside it.
expect(firstBody.data.measurements[0].value).toBe(450);
expect(firstBody.data.measurements[0].mainAsleepMinutes).toBe(420);
expect(firstBody.data.measurements[0].napCount).toBe(1);
// The stage read covered the page's nights, not the history.
const read = spy.mock.calls
.map(
([args]) =>
args as {
where?: { AND?: Array<{ measuredAt?: { gte?: Date } }> };
},
)
.find((args) => args?.where?.AND?.[0]?.measuredAt?.gte);
const readFrom = read!.where!.AND![0].measuredAt!.gte!.getTime();
expect(morning.getTime() - readFrom).toBeLessThan(26 * day);
// Walk every page: each night exactly once, newest first, back to the
// oldest one two years ago.
const seen: string[] = [];
for (let offset = 0; offset < NIGHTS; offset += 100) {
const res = await callList(
`type=SLEEP_DURATION&limit=100&offset=${offset}`,
);
const body = (await res.json()) as ListEnvelope;
expect(body.data.meta.total).toBe(NIGHTS);
for (const row of body.data.measurements) {
seen.push(row.dayKey!);
expect(row.mainAsleepMinutes).toBe(420);
expect(row.value).toBe(row.napCount ? 450 : 420);
}
}
expect(seen).toHaveLength(NIGHTS);
expect(new Set(seen).size).toBe(NIGHTS);
expect(seen).toEqual([...seen].sort().reverse());
const oldest = dayKeyOf(new Date(morning.getTime() - (NIGHTS - 1) * day));
expect(seen[seen.length - 1]).toBe(oldest);
// Past the end: an empty page that still reports the total.
const past = (await (
await callList(`type=SLEEP_DURATION&limit=25&offset=${NIGHTS + 25}`)
).json()) as ListEnvelope;
expect(past.data.measurements).toEqual([]);
expect(past.data.meta.total).toBe(NIGHTS);
// Ascending starts at the oldest night; a window counts only its nights.
const asc = (await (
await callList("type=SLEEP_DURATION&limit=5&sortDir=asc")
).json()) as ListEnvelope;
expect(asc.data.measurements[0].dayKey).toBe(oldest);
const recent = (await (
await callList(
`type=SLEEP_DURATION&from=${encodeURIComponent(new Date(morning.getTime() - 10.5 * day).toISOString())}`,
)
).json()) as ListEnvelope;
expect(recent.data.meta.total).toBe(11);
// A night older than a year still drills down.
const drill = (await (
await callList(`type=SLEEP_DURATION&dayKey=${oldest}`)
).json()) as ListEnvelope;
expect(drill.data.measurements).toHaveLength(1);
expect(drill.data.measurements[0].sleepStage).toBe("CORE");
}, 240_000);
// Review finding on the SQL paging: the page labels a session by the last
// row of ANY writer, the reconstruction by its winning writer. A watch that
// stops before midnight beside a phone whose in-bed span runs past it gave
// two different days, and matching by label dropped the night from both.
it("keeps a night whose losing writer runs past midnight", async () => {
const prisma = getPrismaClient();
const user = await seedSession("sleep-midnight-user");
await prisma.user.update({
where: { id: user.id },
data: { timezone: "UTC" },
});
const base = {
userId: user.id,
type: "SLEEP_DURATION" as const,
unit: "minutes",
source: "APPLE_HEALTH" as const,
};
await prisma.measurement.createMany({
data: [
// The watch wins the device ladder and ends at 23:50.
{
...base,
value: 420,
measuredAt: new Date("2026-03-10T23:50:00.000Z"),
externalId: "uuid-watch-core",
sleepStage: "CORE" as const,
deviceType: "watch",
},
// The phone's in-bed span ends at 00:20 the next day.
{
...base,
value: 450,
measuredAt: new Date("2026-03-11T00:20:00.000Z"),
externalId: "uuid-phone-inbed",
sleepStage: "IN_BED" as const,
deviceType: "phone",
},
],
});
const list = (await (
await callList("type=SLEEP_DURATION")
).json()) as ListEnvelope;
expect(list.data.meta.total).toBe(1);
// The night is listed, not lost between two labels.
expect(list.data.measurements).toHaveLength(1);
const [night] = list.data.measurements;
expect(night.value).toBe(420);
// Its drill-down, asked by the label the list gave it, finds it too.
const drill = (await (
await callList(`type=SLEEP_DURATION&dayKey=${night.dayKey}`)
).json()) as ListEnvelope;
expect(drill.data.measurements.map((m) => m.sleepStage)).toEqual(["CORE"]);
});
});