/** * 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 { 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 = { "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); }); });