Files
HealthLog/tests/integration/cycle-flow-opens-cycle.test.ts
T
Marc-André Bombeck 7d25bb7352 fix(cycle): log past dates as themselves, in any order (#1004)
The log sheet took its "Day N" header and its "period ended" button
from the verdict, which is the answer about today. A January date opened
in September read "Day 2", and a back-dated period could only be closed
on dates where today happened to be a bleeding day, so the button came
and went as unrelated months were filled in.

The calendar read now carries, per date, the cycle day of the logged
cycle that date belongs to (null before the first start, after today, or
past the verdict's ceiling for an open cycle) and whether a one-tap
period end can land there. The sheet reads both off the grid day for the
date it opened on.

Saving a new entry with nothing picked used to write an empty row: the
request succeeded, the sheet closed and the calendar had nothing to
draw, which read as a save that did nothing. Save now needs something to
store and says what, and an emptied existing entry still saves. The
sheet also stops writing until the opened date's own entry has loaded,
because until then it held the previous date's row id and a quick Save
patched the wrong day.

On the server, a start recorded a few days after an earlier one is the
same period entered in the other order: opening the earlier start now
folds the later one into it instead of leaving a one-day cycle in front
of it, and a bleed too far before the next start to be its first day and
too close to be a cycle of its own no longer infers a start. Opening or
removing a start re-attributes the day-logs whose cycle changed.
2026-09-24 08:02:00 +02:00

402 lines
15 KiB
TypeScript

/**
* A period logged as flow has to count as a period.
*
* `MenstrualCycle` rows were only ever written by the one-tap period
* boundary. Everything else that records bleeding — the log sheet's flow
* chips, the bulk drain, an Apple Health export full of menstrual-flow
* samples — wrote a `CycleDayLog` and nothing more, and the engine reads
* cycle rows. So a person could log every period they had and the module
* still held that none had ever happened: no cycle length, no forecast, an
* empty chart, and an offer to log a first period. An Apple Health user who
* never found the one-tap button had the whole module dead.
*
* The rule the fix applies is deliberately conservative, and these cases are
* as much about what must NOT open a cycle as what must. Spotting does not
* (it is as often the tail or the herald of a period as its first day), an
* entry flagged as bleeding between periods does not, and a bleeding day
* inside an existing cycle's plausible span does not — otherwise day three of
* a period, or a forgotten day two, would each start a cycle of their own.
*/
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-flow-opens-integration-32-bytes-min-1234567890";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const USER_ID = "user-cycle-flow";
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-flow",
email: "cycle-flow@example.test",
gender: "FEMALE",
// The suite runs under TZ=UTC and computes its relative day keys with
// local Date arithmetic, i.e. in UTC. The route resolves "today" in the
// USER's zone and falls back to Europe/Berlin when none is set - one
// hour past 22:00 UTC the two clocks disagree about the date and every
// day-of-cycle assertion is off by one. Pinning the fixture zone to UTC
// makes the test's clock and the route's clock the same clock at any
// hour of the day.
timezone: "UTC",
},
});
await loginAs(USER_ID);
});
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,
});
}
/** Log one day through the real capture route. */
async function logDay(
date: string,
fields: Record<string, unknown>,
): Promise<Response> {
const { POST } = await import("@/app/api/cycle/day-logs/route");
return POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date,
loggedAt: `${date}T08:00:00.000Z`,
...fields,
}),
);
}
/** The live cycle starts, oldest first. */
async function cycleStarts(): Promise<string[]> {
const rows = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { startDate: true },
});
return rows.map((r) => r.startDate);
}
describe("a bleeding day opens a cycle", () => {
it("turns the first flow entry into a cycle the engine can see", async () => {
const res = await logDay("2026-05-04", { flow: "HEAVY" });
expect(res.status).toBe(201);
expect(await cycleStarts()).toEqual(["2026-05-04"]);
});
it("does not open a second cycle for the rest of the same period", async () => {
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-05-05", { flow: "MEDIUM" });
// Day three with day two missing: the gap must not read as a new period.
await logDay("2026-05-07", { flow: "LIGHT" });
expect(await cycleStarts()).toEqual(["2026-05-04"]);
});
it("opens the next cycle when the next period arrives", async () => {
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-06-01", { flow: "HEAVY" });
await logDay("2026-06-29", { flow: "MEDIUM" });
expect(await cycleStarts()).toEqual([
"2026-05-04",
"2026-06-01",
"2026-06-29",
]);
});
it("leaves spotting alone", async () => {
await logDay("2026-05-04", { flow: "SPOTTING" });
expect(await cycleStarts()).toEqual([]);
});
it("leaves bleeding flagged as between periods alone", async () => {
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-05-20", {
flow: "MEDIUM",
intermenstrualBleeding: true,
});
expect(await cycleStarts()).toEqual(["2026-05-04"]);
});
it("leaves a day with no flow alone", async () => {
await logDay("2026-05-04", { basalBodyTempC: 36.4 });
expect(await cycleStarts()).toEqual([]);
});
it("re-anchors the neighbours of a back-dated bleed", async () => {
// The same boundary work the one-tap start does: the inserted cycle takes
// its end from the cycle after it, and the one before it closes.
await logDay("2026-06-01", { flow: "HEAVY" });
await logDay("2026-07-01", { flow: "HEAVY" });
// Remembered later, and earlier than both.
await logDay("2026-05-04", { flow: "HEAVY" });
const rows = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { startDate: true, endDate: true, lengthDays: true },
});
expect(rows).toEqual([
{ startDate: "2026-05-04", endDate: "2026-05-31", lengthDays: 28 },
{ startDate: "2026-06-01", endDate: "2026-06-30", lengthDays: 30 },
{ startDate: "2026-07-01", endDate: null, lengthDays: null },
]);
});
it("does not duplicate the cycle the one-tap start already opened", async () => {
const { POST } = await import("@/app/api/cycle/period/route");
await POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-05-04",
loggedAt: "2026-05-04T08:00:00.000Z",
}),
);
// The boundary write logs MEDIUM for the day; re-saving it richer must not
// open a second cycle on the same date.
await logDay("2026-05-04", { flow: "HEAVY" });
expect(await cycleStarts()).toEqual(["2026-05-04"]);
});
it("opens a cycle when an edit turns a day into a bleeding day", async () => {
await logDay("2026-05-04", { basalBodyTempC: 36.4 });
expect(await cycleStarts()).toEqual([]);
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-05-04" },
select: { id: true },
});
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
const res = await PATCH(
jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", {
flow: "HEAVY",
}),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect(res.status).toBe(200);
expect(await cycleStarts()).toEqual(["2026-05-04"]);
});
it("removes the cycle again when an edit clears the flow", async () => {
// The inverse of the case above. A cycle that can be created by an edit
// and not undone by one leaves the record holding a period that nothing on
// the day says happened.
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-06-01", { flow: "HEAVY" });
expect(await cycleStarts()).toEqual(["2026-05-04", "2026-06-01"]);
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-06-01" },
select: { id: true },
});
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
await PATCH(
jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: null }),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect(await cycleStarts()).toEqual(["2026-05-04"]);
// And the May cycle is open again, not left closed against a start that
// no longer exists.
const may = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: "2026-05-04", deletedAt: null },
select: { endDate: true, lengthDays: true },
});
expect(may).toEqual({ endDate: null, lengthDays: null });
});
it("moves a start earlier when an earlier first day is remembered later (#1004)", async () => {
// Day two logged first, day one filled in afterwards. The period began a
// day earlier; it did not begin twice. The old rule only looked at the
// cycle BEFORE the day and opened a one-day cycle in front of the other.
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-06-02", { flow: "HEAVY" });
await logDay("2026-06-01", { flow: "HEAVY" });
const rows = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { startDate: true, endDate: true, lengthDays: true },
});
expect(rows).toEqual([
{ startDate: "2026-05-04", endDate: "2026-05-31", lengthDays: 28 },
{ startDate: "2026-06-01", endDate: null, lengthDays: null },
]);
});
it("lands on the same history whichever order the periods are entered in", async () => {
// September first, then January to August, is what a person back-filling
// a year does. The result must not depend on it.
const starts = [
"2026-01-05",
"2026-02-03",
"2026-03-04",
"2026-04-02",
"2026-05-01",
];
for (const d of [starts[4], ...starts.slice(0, 4)]) {
// Day two of each period first, then its first day.
const dayTwo = new Date(`${d}T12:00:00Z`);
dayTwo.setUTCDate(dayTwo.getUTCDate() + 1);
await logDay(dayTwo.toISOString().slice(0, 10), { flow: "MEDIUM" });
await logDay(d, { flow: "HEAVY" });
}
const outOfOrder = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { startDate: true, endDate: true, lengthDays: true },
});
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "cycle-flow",
email: "cycle-flow@example.test",
gender: "FEMALE",
timezone: "UTC",
},
});
await loginAs(USER_ID);
for (const d of starts) {
await logDay(d, { flow: "HEAVY" });
}
const inOrder = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { startDate: true, endDate: true, lengthDays: true },
});
expect(outOfOrder).toEqual(inOrder);
expect(inOrder.map((r) => r.startDate)).toEqual(starts);
});
it("does not guess a start from a bleed too far before the next period to be its first day", async () => {
await logDay("2026-06-15", { flow: "HEAVY" });
// Twelve days earlier: too close to be a cycle of its own, too far to be
// the same period. Nothing is inferred; the one-tap start still decides.
await logDay("2026-06-03", { flow: "HEAVY" });
expect(await cycleStarts()).toEqual(["2026-06-15"]);
});
it("re-attributes the days a back-filled start now owns", async () => {
await logDay("2026-05-01", { flow: "HEAVY" });
await logDay("2026-06-05", { basalBodyTempC: 36.4 });
// A June start remembered afterwards: 5 June belongs to it, not to May.
await logDay("2026-06-01", { flow: "HEAVY" });
const june = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: "2026-06-01", deletedAt: null },
select: { id: true },
});
const day = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-06-05" },
select: { cycleId: true },
});
expect(day.cycleId).toBe(june.id);
});
it("hands a removed start's days back to the cycle before it", async () => {
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-06-01", { flow: "HEAVY" });
await logDay("2026-06-05", { basalBodyTempC: 36.4 });
const start = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-06-01" },
select: { id: true },
});
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
await PATCH(
jsonRequest(`/api/cycle/day-logs/${start.id}`, "PATCH", { flow: null }),
{ params: Promise.resolve({ id: start.id }) },
);
const may = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: "2026-05-04", deletedAt: null },
select: { id: true },
});
const day = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-06-05" },
select: { cycleId: true },
});
expect(day.cycleId).toBe(may.id);
});
it("gives the record a forecast and a cycle day it never had", async () => {
// What the person actually sees. Four periods 28 days apart, logged as
// flow and nothing else, and today inside the last one.
const today = new Date();
const dayKey = (offset: number): string => {
const d = new Date(today);
d.setDate(d.getDate() + offset);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
d.getDate(),
).padStart(2, "0")}`;
};
for (const offset of [-86, -58, -30, -2]) {
await logDay(dayKey(offset), { flow: "HEAVY" });
}
const { GET } = await import("@/app/api/cycle/calendar/route");
const res = await GET(
new NextRequest("http://localhost/api/cycle/calendar"),
);
const body = JSON.parse(await res.text());
expect(body.data.profile.cyclesObserved).toBe(3);
expect(body.data.prediction).not.toBeNull();
expect(body.data.verdict.state).toBe("IN_CYCLE");
expect(body.data.verdict.dayOfCycle).toBe(3);
expect(body.data.verdict.cycleStartDate).toBe(dayKey(-2));
});
});