mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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).
This commit is contained in:
1 parent
0f46bf4b1f
commit
74aa41519f
2 files changed
+216
No files matched your search
@@ -68,6 +68,7 @@ interface AnalyticsEnvelope {
|
||||
nights: number;
|
||||
totalMinutes: number;
|
||||
stages: Record<string, number>;
|
||||
perNight: Array<{ dayKey: string; stages: Record<string, number> }>;
|
||||
} | 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<Response>
|
||||
)(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);
|
||||
});
|
||||
});
|
||||
@@ -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<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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user