Files
HealthLog/tests/integration/cycle-start-round-trip.test.ts
T
Marc-André Bombeck 6db2f6f9ae fix(cycle): make removing a period start restore the neighbour it moved
Opening a cycle closes the one before it: the start write stamps the
previous cycle's endDate and lengthDays, and a back-filled start takes
its own end from its successor. Nothing undid any of that. Deleting the
start left the previous cycle as a closed torso holding an end date from
a cycle that no longer existed, and a record whose last cycle is closed
has no open cycle to forecast from — the prediction went away and there
was no way to bring it back from the app.

Both removal paths now re-derive the boundary from the cycles that
remain: a cycle ends the day before the next one begins, and with no
next one it is open again. That is a re-derivation rather than an undo
log, so it is correct for a history that came from an import or a
restore, and running it twice changes nothing the second time.

Deleting the day-log that opened a cycle now removes that cycle too.
The one-tap start writes both rows and the log sheet's delete is the
only way the web offers to take it back; leaving the cycle standing was
how the broken state survived the undo.

- soft-delete and re-anchor run in one transaction
- the audit row names the neighbour's moved boundary, the only part of
  the write the tombstone cannot reconstruct
- round trip through the real routes: photograph the cycle rows, log the
  start, remove it either way, compare field by field — including that
  the forecast comes back
2026-08-07 15:03:10 +02:00

297 lines
9.8 KiB
TypeScript

/**
* 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<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();
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<CycleShape[]> {
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<CycleShape[]> {
return (await cycleShapes()).filter((c) => !c.deletedAt);
}
async function startPeriod(date: string): Promise<Response> {
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<void> {
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<string | null> => {
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);
});
});