mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
/**
|
|
* Integration suite for the heart-rate curve on `GET /api/workouts/{id}`.
|
|
*
|
|
* The curve is the one part of the workout-detail envelope that is
|
|
* assembled from rows the workout does not own: when a session carries no
|
|
* stored sensor stream, the server reconstructs it from the account's raw
|
|
* `PULSE` measurements around the session. Two boundaries decide whether
|
|
* that reconstruction is honest, and neither is provable from a unit test
|
|
* with a mocked Prisma:
|
|
*
|
|
* - the WINDOW. A reading taken after the session ended is not part of
|
|
* the session. The query pads by five minutes so a boundary reading
|
|
* still lands in the first / last bucket, and the fold then clamps to
|
|
* the session itself — a pad reading must not extend the curve past
|
|
* the finish.
|
|
* - the OWNER. The measurement read is scoped to the calling account.
|
|
* Another account's pulse at the same wall-clock minute must not
|
|
* enter this curve.
|
|
*
|
|
* Both run through the real route against a real Postgres, and the
|
|
* fixture places a reading on the far side of each boundary with a bpm
|
|
* value no in-window reading has, so a leak is visible in the numbers
|
|
* rather than only in a count.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { Prisma } from "@/generated/prisma/client";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
process.env.ENCRYPTION_KEY =
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
const TEST_USER_ID = "user-workout-hr-series";
|
|
const OTHER_USER_ID = "user-workout-hr-series-other";
|
|
|
|
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),
|
|
}));
|
|
|
|
const { GET } = await import("@/app/api/workouts/[id]/route");
|
|
|
|
/**
|
|
* Session window: 07:00 → 07:30 UTC ten days ago. The pulse-window fallback
|
|
* only reconstructs a curve inside the dense-intraday retention horizon
|
|
* (`DENSE_INTRADAY_RETENTION_DAYS`, 90 days), so the fixture day must stay
|
|
* relative to the test run — a fixed calendar date silently aged past the
|
|
* horizon and turned every curve assertion red on day 91.
|
|
*/
|
|
const SESSION_DAY = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
const STARTED_AT = new Date(`${SESSION_DAY}T07:00:00.000Z`);
|
|
const ENDED_AT = new Date(`${SESSION_DAY}T07:30:00.000Z`);
|
|
const SPAN_SEC = 1800;
|
|
|
|
/**
|
|
* In-window readings sit in this band; every out-of-window reading is
|
|
* placed outside it, so an assertion on the curve's bpm bounds names any
|
|
* leak directly.
|
|
*/
|
|
const IN_WINDOW_LOW = 120;
|
|
const IN_WINDOW_HIGH = 168;
|
|
/** After the finish but inside the query's five-minute pad. */
|
|
const PAD_TAIL_BPM = 44;
|
|
/** Well outside the padded window on either side. */
|
|
const FAR_BEFORE_BPM = 201;
|
|
const FAR_AFTER_BPM = 51;
|
|
/** Another account's reading in the middle of this session. */
|
|
const FOREIGN_BPM = 199;
|
|
|
|
interface HrSeriesPoint {
|
|
tSec: number;
|
|
mean: number;
|
|
min: number;
|
|
max: number;
|
|
}
|
|
|
|
interface WorkoutDetailBody {
|
|
data: {
|
|
id: string;
|
|
hrSeries: {
|
|
source: string;
|
|
bucketSec: number;
|
|
envelope: boolean;
|
|
points: HrSeriesPoint[];
|
|
} | null;
|
|
} | null;
|
|
error: string | null;
|
|
}
|
|
|
|
async function createUser(id: string, username: string) {
|
|
await getPrismaClient().user.create({
|
|
data: { id, username, email: `${username}@example.test` },
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await createUser(TEST_USER_ID, "workout-hr-series");
|
|
await createUser(OTHER_USER_ID, "workout-hr-series-other");
|
|
const session = await getPrismaClient().session.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
});
|
|
|
|
/**
|
|
* The session under test, with no stored sample stream — so the route has
|
|
* to reconstruct the curve from `PULSE` rows and the boundaries above are
|
|
* the ones that decide what it sees.
|
|
*/
|
|
async function createWorkout(): Promise<string> {
|
|
const row = await getPrismaClient().workout.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
sportType: "running",
|
|
startedAt: STARTED_AT,
|
|
endedAt: ENDED_AT,
|
|
durationSec: SPAN_SEC,
|
|
avgHeartRate: 144,
|
|
maxHeartRate: IN_WINDOW_HIGH,
|
|
source: "MANUAL",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
return row.id;
|
|
}
|
|
|
|
function pulseRow(userId: string, offsetSec: number, value: number) {
|
|
return {
|
|
userId,
|
|
type: "PULSE" as const,
|
|
value,
|
|
unit: "bpm",
|
|
measuredAt: new Date(STARTED_AT.getTime() + offsetSec * 1000),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A dense in-window sweep plus one reading on the far side of each
|
|
* boundary. The sweep alternates across the in-window band so the fold
|
|
* has a real shape to carry.
|
|
*/
|
|
async function seedPulse() {
|
|
const rows = [] as ReturnType<typeof pulseRow>[];
|
|
for (let offset = 0; offset < SPAN_SEC; offset += 5) {
|
|
const swing = Math.round(
|
|
((IN_WINDOW_HIGH - IN_WINDOW_LOW) / 2) *
|
|
(1 + Math.sin((offset / SPAN_SEC) * Math.PI * 4)),
|
|
);
|
|
rows.push(pulseRow(TEST_USER_ID, offset, IN_WINDOW_LOW + swing));
|
|
}
|
|
// Two minutes after the finish: inside the query's pad, outside the
|
|
// session. The query returns it; the curve must not.
|
|
rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 120, PAD_TAIL_BPM));
|
|
// Far outside the padded window on both sides.
|
|
rows.push(pulseRow(TEST_USER_ID, -20 * 60, FAR_BEFORE_BPM));
|
|
rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 15 * 60, FAR_AFTER_BPM));
|
|
// Another account, mid-session.
|
|
rows.push(pulseRow(OTHER_USER_ID, Math.floor(SPAN_SEC / 2), FOREIGN_BPM));
|
|
await getPrismaClient().measurement.createMany({ data: rows });
|
|
}
|
|
|
|
async function fetchDetail(id: string): Promise<WorkoutDetailBody> {
|
|
const request = new NextRequest(
|
|
`http://localhost/api/workouts/${id}?compact=1`,
|
|
);
|
|
const response = await GET(request, { params: Promise.resolve({ id }) });
|
|
return (await response.json()) as WorkoutDetailBody;
|
|
}
|
|
|
|
describe("GET /api/workouts/{id} — reconstructed heart-rate curve", () => {
|
|
it("reconstructs the curve from the account's pulse readings", async () => {
|
|
const id = await createWorkout();
|
|
await seedPulse();
|
|
|
|
const body = await fetchDetail(id);
|
|
expect(body.error).toBeNull();
|
|
const series = body.data?.hrSeries;
|
|
expect(series).not.toBeNull();
|
|
expect(series!.source).toBe("pulse_window");
|
|
expect(series!.points.length).toBeGreaterThan(2);
|
|
});
|
|
|
|
it("stops the curve at the finish — a reading inside the query pad is not part of the session", async () => {
|
|
const id = await createWorkout();
|
|
await seedPulse();
|
|
|
|
const series = (await fetchDetail(id)).data!.hrSeries!;
|
|
const lastTSec = series.points[series.points.length - 1].tSec;
|
|
expect(lastTSec).toBeLessThan(SPAN_SEC);
|
|
// The pad reading is the only source of a sub-100 bpm value in the
|
|
// fixture, so naming the value proves it stayed out rather than
|
|
// merely proving a count.
|
|
expect(series.points.map((p) => p.min)).not.toContain(PAD_TAIL_BPM);
|
|
expect(Math.min(...series.points.map((p) => p.min))).toBeGreaterThanOrEqual(
|
|
IN_WINDOW_LOW,
|
|
);
|
|
});
|
|
|
|
it("keeps readings from outside the window and from other accounts out of the curve", async () => {
|
|
const id = await createWorkout();
|
|
await seedPulse();
|
|
|
|
const series = (await fetchDetail(id)).data!.hrSeries!;
|
|
const maxima = series.points.map((p) => p.max);
|
|
expect(maxima).not.toContain(FAR_BEFORE_BPM);
|
|
expect(maxima).not.toContain(FOREIGN_BPM);
|
|
expect(series.points.map((p) => p.min)).not.toContain(FAR_AFTER_BPM);
|
|
expect(Math.max(...maxima)).toBeLessThanOrEqual(IN_WINDOW_HIGH);
|
|
});
|
|
|
|
it("serves no curve when the account's readings do not cover the session", async () => {
|
|
const id = await createWorkout();
|
|
// A handful of opportunistic readings in the first minute. Enough
|
|
// rows to clear the sample floor, nowhere near enough to describe a
|
|
// half-hour session — the route says nothing rather than drawing a
|
|
// curve out of a corner of it.
|
|
await getPrismaClient().measurement.createMany({
|
|
data: Array.from({ length: 12 }, (_, i) =>
|
|
pulseRow(TEST_USER_ID, i * 4, 130 + i),
|
|
),
|
|
});
|
|
|
|
const body = await fetchDetail(id);
|
|
expect(body.error).toBeNull();
|
|
expect(body.data?.hrSeries).toBeNull();
|
|
});
|
|
|
|
it("does not reach the reconstruction at all for another account's workout", async () => {
|
|
const foreign = await getPrismaClient().workout.create({
|
|
data: {
|
|
userId: OTHER_USER_ID,
|
|
sportType: "running",
|
|
startedAt: STARTED_AT,
|
|
endedAt: ENDED_AT,
|
|
durationSec: SPAN_SEC,
|
|
source: "MANUAL",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
await seedPulse();
|
|
|
|
const request = new NextRequest(
|
|
`http://localhost/api/workouts/${foreign.id}?compact=1`,
|
|
);
|
|
const response = await GET(request, {
|
|
params: Promise.resolve({ id: foreign.id }),
|
|
});
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/workouts/{id} — a route whose track cannot be read", () => {
|
|
it("reports no route at all instead of a route with a null geometry", async () => {
|
|
const id = await createWorkout();
|
|
// Neither the sealed column nor the legacy readable one holds a track:
|
|
// the shape an unreadable or JSON-null row resolves to.
|
|
await getPrismaClient().workoutRoute.create({
|
|
data: { workoutId: id, geometry: Prisma.JsonNull },
|
|
});
|
|
const body = (await fetchDetail(id)) as unknown as {
|
|
data: { route: unknown };
|
|
};
|
|
expect(body.data.route).toBeNull();
|
|
});
|
|
});
|