mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Add `GET /api/cycle/day-logs?date=YYYY-MM-DD` returning the full day-log DTO (or null) for one tz-anchored day so the web log-day sheet pre-fills instead of opening blank — a blank re-log no longer nulls untouched or HealthKit-sourced fields. Delete resolves the row id from the same read. Persist an optional 1-4 Likert severity per symptom link (migration 0133_v1150_cycle_symptom_severity), carried through the shared symptom-link writer and surfaced in the day-log DTO. Sheet now captures the previously missing fields — spotting between periods, pregnancy/progesterone home-test results, contraceptive kind — plus per-symptom severity chips, a one-tap end-period boundary, and a Delete action. The wheel ring marker sits at the 12-o'clock origin again (drop the double rotation), the cycle view goes two-column on desktop, and the empty ring carries a first-period call to action. New copy is localized across all six locales; the prediction and fertility surfaces keep their disclaimers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
544 lines
18 KiB
TypeScript
544 lines
18 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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|