/** * Logging a period start and taking it back has to leave the record where it * found it. * * `action:"start"` does not only insert a cycle. It CLOSES the previous open * one — stamps its `endDate` and `lengthDays` from the new start — and, when * the start is back-filled between two existing cycles, re-anchors the one * after it too. Nothing undid any of that. Deleting the start left the * previous cycle standing as a closed torso, and a record whose last cycle is * closed has no open cycle to forecast from, so the prediction disappeared and * did not come back. There is no way to repair that from the app. * * So this is a round trip, not a delete test: it photographs every cycle row * before the start is logged, logs it, removes it, and asserts the rows are * back to the photograph field by field. Two removal paths reach a start — * deleting the cycle, and deleting the day-log that opened it from the log * sheet, which is the only one the web actually offers. */ 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-round-trip-integration-32-bytes-min-1234567890"; process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const USER_ID = "user-cycle-round-trip"; 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(); await getPrismaClient().user.create({ data: { id: USER_ID, username: "cycle-round-trip", email: "cycle-round-trip@example.test", gender: "FEMALE", }, }); }); function jsonRequest( path: string, method: string, body?: unknown, ): NextRequest { return new NextRequest(`http://localhost${path}`, { method, headers: { "content-type": "application/json" }, body: body !== undefined ? JSON.stringify(body) : undefined, }); } /** The cycle boundary state, which is exactly what a start write moves. */ interface CycleShape { startDate: string; endDate: string | null; lengthDays: number | null; periodEndDate: string | null; deletedAt: boolean; } async function cycleShapes(): Promise { const rows = await getPrismaClient().menstrualCycle.findMany({ where: { userId: USER_ID }, orderBy: { startDate: "asc" }, select: { startDate: true, endDate: true, lengthDays: true, periodEndDate: true, deletedAt: true, }, }); return rows.map((r) => ({ startDate: r.startDate, endDate: r.endDate, lengthDays: r.lengthDays, periodEndDate: r.periodEndDate, deletedAt: r.deletedAt !== null, })); } /** Only the cycles a read would see — a tombstone is not part of the record. */ async function liveShapes(): Promise { return (await cycleShapes()).filter((c) => !c.deletedAt); } async function startPeriod(date: string): Promise { const { POST } = await import("@/app/api/cycle/period/route"); return POST( jsonRequest("/api/cycle/period", "POST", { action: "start", date, loggedAt: `${date}T08:00:00.000Z`, }), ); } /** The three months of history the maintainer's record carried. */ async function seedHistory(): Promise { await startPeriod("2026-05-04"); await startPeriod("2026-06-01"); await startPeriod("2026-07-01"); } describe("logging a period start and taking it back", () => { it("re-opens the previous cycle when the start's own cycle is deleted", async () => { await loginAs(USER_ID); await seedHistory(); const before = await liveShapes(); // The July cycle is open — that is the state the record has to return to. expect(before.at(-1)).toEqual({ startDate: "2026-07-01", endDate: null, lengthDays: null, periodEndDate: null, deletedAt: false, }); await startPeriod("2026-08-07"); const during = await liveShapes(); expect(during).toHaveLength(4); // The July cycle really was closed by the new start, so the delete below // has something to undo. expect(during.at(-2)).toMatchObject({ startDate: "2026-07-01", endDate: "2026-08-06", lengthDays: 37, }); const added = await getPrismaClient().menstrualCycle.findFirstOrThrow({ where: { userId: USER_ID, startDate: "2026-08-07" }, select: { id: true }, }); const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route"); const res = await DELETE( jsonRequest(`/api/cycle/cycles/${added.id}`, "DELETE"), { params: Promise.resolve({ id: added.id }) }, ); expect(res.status).toBe(204); expect(await liveShapes()).toEqual(before); }); it("re-opens the previous cycle when the start's day-log is deleted", async () => { // The path the web actually offers: the log sheet's delete button removes // the day-log, and the cycle that one tap opened has to go with it. await loginAs(USER_ID); await seedHistory(); const before = await liveShapes(); await startPeriod("2026-08-07"); expect(await liveShapes()).toHaveLength(4); const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ where: { userId: USER_ID, date: "2026-08-07" }, select: { id: true }, }); const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route"); const res = await DELETE( jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"), { params: Promise.resolve({ id: dayLog.id }) }, ); expect(res.status).toBe(204); expect(await liveShapes()).toEqual(before); }); it("restores the boundary a back-filled start took from BOTH neighbours", async () => { // A start inserted between two existing cycles closes the one before it // and takes its own end from the one after. Removing it has to hand the // whole span back to the earlier cycle. await loginAs(USER_ID); await seedHistory(); await startPeriod("2026-08-07"); const before = await liveShapes(); await startPeriod("2026-06-15"); const during = await liveShapes(); expect(during).toHaveLength(5); expect(during[1]).toMatchObject({ startDate: "2026-06-01", endDate: "2026-06-14", lengthDays: 14, }); expect(during[2]).toMatchObject({ startDate: "2026-06-15", endDate: "2026-06-30", lengthDays: 16, }); const inserted = await getPrismaClient().menstrualCycle.findFirstOrThrow({ where: { userId: USER_ID, startDate: "2026-06-15" }, select: { id: true }, }); const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route"); await DELETE(jsonRequest(`/api/cycle/cycles/${inserted.id}`, "DELETE"), { params: Promise.resolve({ id: inserted.id }), }); expect(await liveShapes()).toEqual(before); }); it("leaves a day-log that opened no cycle alone", async () => { // A plain entry on a day that is not a cycle start must not take a cycle // with it when it is deleted. await loginAs(USER_ID); await seedHistory(); const before = await liveShapes(); const { POST } = await import("@/app/api/cycle/day-logs/route"); await POST( jsonRequest("/api/cycle/day-logs", "POST", { date: "2026-07-14", flow: "SPOTTING", loggedAt: "2026-07-14T08:00:00.000Z", }), ); const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ where: { userId: USER_ID, date: "2026-07-14" }, select: { id: true }, }); const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route"); await DELETE(jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"), { params: Promise.resolve({ id: dayLog.id }), }); expect(await liveShapes()).toEqual(before); }); it("gives the forecast back", async () => { // The symptom the round trip exists for: after the start is taken back the // engine must forecast again, from the same open cycle as before. await loginAs(USER_ID); await seedHistory(); const { GET } = await import("@/app/api/cycle/calendar/route"); const readForecast = async (): Promise => { const res = await GET( new NextRequest("http://localhost/api/cycle/calendar"), ); const body = JSON.parse(await res.text()); return body.data?.prediction?.nextPeriodStart ?? null; }; const before = await readForecast(); expect(before).not.toBeNull(); await startPeriod("2026-08-07"); const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ where: { userId: USER_ID, date: "2026-08-07" }, select: { id: true }, }); const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route"); await DELETE(jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"), { params: Promise.resolve({ id: dayLog.id }), }); expect(await readForecast()).toBe(before); }); });