Files
HealthLog/tests/integration/cycle-routes.test.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

588 lines
19 KiB
TypeScript

/**
* Integration suite for the v1.15.0 cycle-tracking routes against a real
* Postgres in a testcontainer. Asserts the iOS-facing contract end-to-end:
*
* - the feature gate (`cycle.disabled` 403) keys on gender + the
* `cycleTrackingEnabled` toggle, even with a valid session
* - single day-log capture upserts (201 insert → 200 update), encrypts
* the note, and reads it back decrypted
* - the bulk drain returns per-entry inserted / updated / duplicate
* - the period shortcut opens a cycle + boundary day-log
* - the calendar read runs the engine + goal-gates the fertile window
* - the cycle-prefs PATCH flips the gate for a non-FEMALE account
* - `/api/sync/changes` carries the cycleDays + cycles domains incl.
* tombstones after a soft-delete
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-cycle-routes-integration-32-bytes-min-1234567890";
// Crypto-at-rest needs a key for the note encrypt/decrypt round-trip.
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const FEMALE_USER_ID = "user-cycle-female";
const MALE_USER_ID = "user-cycle-male";
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),
}));
async function loginAs(userId: string): Promise<void> {
cookieJar.clear();
headerJar.clear();
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: FEMALE_USER_ID,
username: "cycle-female",
email: "cycle-female@example.test",
gender: "FEMALE",
},
});
await prisma.user.create({
data: {
id: MALE_USER_ID,
username: "cycle-male",
email: "cycle-male@example.test",
gender: "MALE",
},
});
});
function jsonRequest(
path: string,
method: string,
body?: unknown,
opts: { idempotencyKey?: string } = {},
): NextRequest {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (opts.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
return new NextRequest(`http://localhost${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
describe("cycle routes — feature gate", () => {
it("403s a MALE account on every cycle route", async () => {
await loginAs(MALE_USER_ID);
const { POST } = await import("@/app/api/cycle/day-logs/route");
const res = await POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-03-01",
flow: "MEDIUM",
loggedAt: "2026-03-01T08:00:00.000Z",
}),
);
expect(res.status).toBe(403);
const json = await res.json();
expect(json.meta?.errorCode).toBe("cycle.disabled");
});
it("admits a FEMALE account by gender derivation", async () => {
await loginAs(FEMALE_USER_ID);
const { POST } = await import("@/app/api/cycle/day-logs/route");
const res = await POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-03-01",
flow: "MEDIUM",
loggedAt: "2026-03-01T08:00:00.000Z",
}),
);
expect(res.status).toBe(201);
});
});
describe("cycle day-logs — single capture", () => {
it("upserts (201 insert → 200 update) and round-trips the encrypted note", async () => {
await loginAs(FEMALE_USER_ID);
const { POST } = await import("@/app/api/cycle/day-logs/route");
const insert = await POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-03-02",
flow: "LIGHT",
note: "tender, mild cramps",
loggedAt: "2026-03-02T08:00:00.000Z",
}),
);
expect(insert.status).toBe(201);
const insertJson = await insert.json();
expect(insertJson.data.flow).toBe("LIGHT");
expect(insertJson.data.note).toBe("tender, mild cramps");
// The note column is ciphertext, not plaintext.
const row = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: FEMALE_USER_ID, date: "2026-03-02" },
});
expect(row.notesEncrypted).not.toBeNull();
expect(row.notesEncrypted).not.toContain("cramps");
const update = await POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-03-02",
flow: "HEAVY",
loggedAt: "2026-03-02T09:00:00.000Z",
}),
);
expect(update.status).toBe(200);
const updateJson = await update.json();
expect(updateJson.data.flow).toBe("HEAVY");
expect(updateJson.data.id).toBe(insertJson.data.id);
});
});
describe("cycle day-logs — single read (GET)", () => {
it("returns the full DTO for a logged day and null for an empty one", async () => {
await loginAs(FEMALE_USER_ID);
const route = await import("@/app/api/cycle/day-logs/route");
await route.POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-04-01",
flow: "MEDIUM",
loggedAt: "2026-04-01T08:00:00.000Z",
}),
);
const hit = await route.GET(
jsonRequest("/api/cycle/day-logs?date=2026-04-01", "GET"),
);
expect(hit.status).toBe(200);
const hitJson = await hit.json();
expect(hitJson.data).not.toBeNull();
expect(hitJson.data.flow).toBe("MEDIUM");
expect(typeof hitJson.data.id).toBe("string");
const miss = await route.GET(
jsonRequest("/api/cycle/day-logs?date=2026-04-09", "GET"),
);
expect(miss.status).toBe(200);
expect((await miss.json()).data).toBeNull();
});
it("422s a malformed date query", async () => {
await loginAs(FEMALE_USER_ID);
const { GET } = await import("@/app/api/cycle/day-logs/route");
const res = await GET(jsonRequest("/api/cycle/day-logs?date=nope", "GET"));
expect(res.status).toBe(422);
});
it("round-trips the cervix observation signs through POST → GET", async () => {
await loginAs(FEMALE_USER_ID);
const route = await import("@/app/api/cycle/day-logs/route");
await route.POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-05-02",
cervixPosition: "HIGH",
cervixFirmness: "SOFT",
cervixOpening: "OPEN",
loggedAt: "2026-05-02T08:00:00.000Z",
}),
);
const hit = await route.GET(
jsonRequest("/api/cycle/day-logs?date=2026-05-02", "GET"),
);
expect(hit.status).toBe(200);
const { data } = await hit.json();
expect(data.cervixPosition).toBe("HIGH");
expect(data.cervixFirmness).toBe("SOFT");
expect(data.cervixOpening).toBe("OPEN");
});
});
describe("cycle day-logs — symptom severity", () => {
it("persists a 1-4 severity per link and reads it back in the DTO", async () => {
await loginAs(FEMALE_USER_ID);
// Ensure a catalog category + symptom exist so the link resolves
// (idempotent — the seed migration may already provide them).
const cat = await getPrismaClient().cycleSymptomCategory.upsert({
where: { key: "physical" },
create: { key: "physical", labelKey: "cycle.symptomCategory.physical" },
update: {},
});
await getPrismaClient().cycleSymptom.upsert({
where: { key: "cramps" },
create: {
key: "cramps",
categoryId: cat.id,
labelKey: "cycle.symptom.cramps",
isActive: true,
},
update: { isActive: true },
});
const route = await import("@/app/api/cycle/day-logs/route");
const insert = await route.POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-04-15",
symptoms: [{ key: "cramps", severity: 3 }],
loggedAt: "2026-04-15T08:00:00.000Z",
}),
);
expect(insert.status).toBe(201);
const insertJson = await insert.json();
expect(insertJson.data.symptoms).toEqual([{ key: "cramps", severity: 3 }]);
const link = await getPrismaClient().cycleSymptomLink.findFirstOrThrow({
where: { dayLog: { date: "2026-04-15" } },
});
expect(link.severity).toBe(3);
const read = await route.GET(
jsonRequest("/api/cycle/day-logs?date=2026-04-15", "GET"),
);
expect((await read.json()).data.symptoms).toEqual([
{ key: "cramps", severity: 3 },
]);
});
});
describe("cycle day-logs — bulk drain", () => {
it("returns per-entry inserted / updated / duplicate", async () => {
await loginAs(FEMALE_USER_ID);
const { POST } = await import("@/app/api/cycle/day-logs/bulk/route");
const first = await POST(
jsonRequest("/api/cycle/day-logs/bulk", "POST", {
entries: [
{
date: "2026-03-05",
flow: "MEDIUM",
source: "APPLE_HEALTH",
externalId: "hk-aaa",
loggedAt: "2026-03-05T08:00:00.000Z",
},
],
}),
);
expect(first.status).toBe(200);
const firstJson = await first.json();
expect(firstJson.data.inserted).toBe(1);
expect(firstJson.data.entries[0].status).toBe("inserted");
// Re-post unchanged → duplicate; re-post changed → updated.
const second = await POST(
jsonRequest("/api/cycle/day-logs/bulk", "POST", {
entries: [
{
date: "2026-03-05",
flow: "MEDIUM",
source: "APPLE_HEALTH",
externalId: "hk-aaa",
loggedAt: "2026-03-05T08:00:00.000Z",
},
{
date: "2026-03-05",
flow: "HEAVY",
source: "APPLE_HEALTH",
externalId: "hk-aaa",
loggedAt: "2026-03-05T08:00:00.000Z",
},
],
}),
);
const secondJson = await second.json();
expect(secondJson.data.entries[0].status).toBe("duplicate");
expect(secondJson.data.entries[1].status).toBe("updated");
});
});
describe("cycle period shortcut", () => {
it("opens a cycle + writes the boundary day-log", async () => {
await loginAs(FEMALE_USER_ID);
const { POST } = await import("@/app/api/cycle/period/route");
const res = await POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-03-10",
loggedAt: "2026-03-10T08:00:00.000Z",
}),
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.cycle.startDate).toBe("2026-03-10");
expect(json.data.dayLog.flow).toBe("MEDIUM");
});
it("closes the prior cycle atomically when a new start is inserted", async () => {
await loginAs(FEMALE_USER_ID);
const prisma = getPrismaClient();
const { POST } = await import("@/app/api/cycle/period/route");
await POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-04-01",
loggedAt: "2026-04-01T08:00:00.000Z",
}),
);
await POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-04-29",
loggedAt: "2026-04-29T08:00:00.000Z",
}),
);
const prior = await prisma.menstrualCycle.findFirstOrThrow({
where: { userId: FEMALE_USER_ID, startDate: "2026-04-01" },
});
// Close-prior + open-new ran as one unit: the prior cycle is stamped.
expect(prior.endDate).toBe("2026-04-28");
expect(prior.lengthDays).toBe(28);
});
it("replays the same idempotency key without a second mutation", async () => {
await loginAs(FEMALE_USER_ID);
const prisma = getPrismaClient();
const { POST } = await import("@/app/api/cycle/period/route");
const key = `period-${Date.now()}`;
const first = await POST(
jsonRequest(
"/api/cycle/period",
"POST",
{
action: "start",
date: "2026-05-10",
loggedAt: "2026-05-10T08:00:00.000Z",
},
{ idempotencyKey: key },
),
);
expect(first.status).toBe(200);
const replay = await POST(
jsonRequest(
"/api/cycle/period",
"POST",
{
action: "start",
date: "2026-05-10",
loggedAt: "2026-05-10T08:00:00.000Z",
},
{ idempotencyKey: key },
),
);
expect(replay.status).toBe(200);
const cycles = await prisma.menstrualCycle.count({
where: {
userId: FEMALE_USER_ID,
startDate: "2026-05-10",
deletedAt: null,
},
});
expect(cycles).toBe(1);
});
it("never downgrades a richer same-day flow on the boundary write", async () => {
await loginAs(FEMALE_USER_ID);
const prisma = getPrismaClient();
const dayLogs = await import("@/app/api/cycle/day-logs/route");
const period = await import("@/app/api/cycle/period/route");
// A manual HEAVY entry exists for the day…
await dayLogs.POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-06-01",
flow: "HEAVY",
loggedAt: "2026-06-01T07:00:00.000Z",
}),
);
// …a one-tap "start" (boundary flow MEDIUM) must not downgrade it.
await period.POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-06-01",
loggedAt: "2026-06-01T08:00:00.000Z",
}),
);
const row = await prisma.cycleDayLog.findFirstOrThrow({
where: { userId: FEMALE_USER_ID, date: "2026-06-01", deletedAt: null },
});
expect(row.flow).toBe("HEAVY");
});
});
describe("cycle calendar", () => {
it("runs the engine and goal-gates the fertile window", async () => {
await loginAs(FEMALE_USER_ID);
const prisma = getPrismaClient();
// Three ~28d cycles → a real prediction.
for (const [start, end, len] of [
["2026-01-01", "2026-01-28", 28],
["2026-01-29", "2026-02-25", 28],
] as const) {
await prisma.menstrualCycle.create({
data: {
userId: FEMALE_USER_ID,
startDate: start,
endDate: end,
lengthDays: len,
periodEndDate: null,
},
});
}
await prisma.menstrualCycle.create({
data: { userId: FEMALE_USER_ID, startDate: "2026-02-26" },
});
const { GET } = await import("@/app/api/cycle/calendar/route");
const res = await GET(
new NextRequest(
"http://localhost/api/cycle/calendar?from=2026-02-20&to=2026-04-15",
),
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.prediction).not.toBeNull();
expect(json.data.days.length).toBeGreaterThan(0);
// GENERAL_HEALTH default → fertile window suppressed.
expect(json.data.prediction.fertileWindowStart).toBeNull();
// The cache row is persisted fire-and-forget (the route never blocks
// the read on it), so poll briefly for it to land.
let cache = null;
for (let i = 0; i < 20 && cache === null; i++) {
cache = await prisma.cyclePrediction.findUnique({
where: { userId: FEMALE_USER_ID },
});
if (cache === null) await new Promise((r) => setTimeout(r, 25));
}
expect(cache).not.toBeNull();
});
});
describe("cycle-prefs gate flip", () => {
it("enables a MALE account via the prefs PATCH, then admits cycle routes", async () => {
await loginAs(MALE_USER_ID);
const { PATCH } = await import("@/app/api/auth/me/cycle-prefs/route");
const patch = await PATCH(
jsonRequest("/api/auth/me/cycle-prefs", "PATCH", { enabled: true }),
);
expect(patch.status).toBe(200);
const patchJson = await patch.json();
expect(patchJson.data.cycleTrackingEnabled).toBe(true);
// Re-login (fresh session) and confirm the gate now opens.
await loginAs(MALE_USER_ID);
const { POST } = await import("@/app/api/cycle/day-logs/route");
const res = await POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-03-15",
flow: "LIGHT",
loggedAt: "2026-03-15T08:00:00.000Z",
}),
);
expect(res.status).toBe(201);
});
it("defaults secondarySymptom to MUCUS and persists a switch to CERVIX", async () => {
await loginAs(FEMALE_USER_ID);
const { GET, PATCH } = await import("@/app/api/auth/me/cycle-prefs/route");
const initial = await GET();
expect((await initial.json()).data.secondarySymptom).toBe("MUCUS");
const patched = await PATCH(
jsonRequest("/api/auth/me/cycle-prefs", "PATCH", {
secondarySymptom: "CERVIX",
}),
);
expect(patched.status).toBe(200);
expect((await patched.json()).data.secondarySymptom).toBe("CERVIX");
});
});
describe("sync/changes — cycle domains", () => {
it("carries cycleDays + cycles upserts and tombstones after a soft-delete", async () => {
await loginAs(FEMALE_USER_ID);
const prisma = getPrismaClient();
const cycle = await prisma.menstrualCycle.create({
data: { userId: FEMALE_USER_ID, startDate: "2026-03-20" },
});
const dayLog = await prisma.cycleDayLog.create({
data: {
userId: FEMALE_USER_ID,
date: "2026-03-20",
flow: "MEDIUM",
source: "APPLE_HEALTH",
externalId: "hk-sync-1",
},
});
const { GET } = await import("@/app/api/sync/changes/route");
const first = await GET(
new NextRequest("http://localhost/api/sync/changes"),
);
const firstJson = await first.json();
expect(firstJson.data.changes.cycleDays.upserts.length).toBe(1);
expect(firstJson.data.changes.cycles.upserts.length).toBe(1);
expect(firstJson.data.changes.cycleDays.upserts[0].externalId).toBe(
"hk-sync-1",
);
// Soft-delete both → next pull surfaces tombstones.
await prisma.cycleDayLog.update({
where: { id: dayLog.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
await prisma.menstrualCycle.update({
where: { id: cycle.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
const second = await GET(
new NextRequest("http://localhost/api/sync/changes"),
);
const secondJson = await second.json();
const dayTombs = secondJson.data.changes.cycleDays.tombstones;
const cycleTombs = secondJson.data.changes.cycles.tombstones;
expect(dayTombs.length).toBe(1);
expect(dayTombs[0].externalId).toBe("hk-sync-1");
expect(cycleTombs.length).toBe(1);
expect(cycleTombs[0].id).toBe(cycle.id);
});
});