diff --git a/src/__tests__/cycle-verdict-guard-defeat.test.ts b/src/__tests__/cycle-verdict-guard-defeat.test.ts new file mode 100644 index 000000000..b3b510196 --- /dev/null +++ b/src/__tests__/cycle-verdict-guard-defeat.test.ts @@ -0,0 +1,193 @@ +/** + * The cycle-verdict guard, checked against itself. + * + * A structural check that cannot fail reads as proof and is not. So each case + * below is a disguise someone would plausibly reach for while putting the + * client-side cycle derivation back — renaming the constant, inlining the + * number, lifting it into a variable one line above the comparison (the move + * that defeated a guard in this repo before), hiding it in an object, in a + * default parameter, behind a helper — and each is asserted to be caught by + * the SAME detector the real guard runs. + * + * The second half matters just as much: a detector that flags everything is + * a detector nobody keeps. So the innocent shapes are pinned too, including + * the one the client is SUPPOSED to write. + * + * These sources are strings on purpose. They are the shapes that must not + * ship, so none of them may live in a file the app can import. + */ +import { describe, expect, it } from "vitest"; + +import { + derivationFindings, + parse, + type FindingKind, +} from "./helpers/cycle-verdict-derivation"; + +const FILE = "disguise.tsx"; + +/** Every cycle-context source needs the vocabulary a real derivation would use. */ +const PREAMBLE = `import type { CalendarDay, CyclePhase } from "./types";\n`; + +function kinds(source: string): FindingKind[] { + return derivationFindings(parse(FILE, PREAMBLE + source)).map((f) => f.kind); +} + +describe("the guard catches the derivation it was written for", () => { + it("catches the exact constant that was copied", () => { + expect( + kinds(`const OVERDUE_GRACE_DAYS = 14; + export function wheel(days: CalendarDay[]) { return days; }`), + ).toContain("grace-constant"); + }); + + it("catches it renamed — it reads the shape, not the old name", () => { + expect(kinds(`const graceWindow = 14;`)).toContain("grace-constant"); + expect(kinds(`const overdueAfterDays = 21;`)).toContain("grace-constant"); + }); + + it("catches it hidden in an object literal", () => { + expect( + kinds(`const tuning = { overdueGraceDays: 14, cycleLength: 28 };`), + ).toContain("grace-constant"); + }); + + it("catches it hidden in a default parameter", () => { + expect( + kinds( + `export function wheel(days: CalendarDay[], graceDays = 14) { return days.length > graceDays; }`, + ), + ).toContain("grace-constant"); + }); + + it("catches the verdict being computed rather than read", () => { + expect( + kinds( + `const dayOfCycle = 43; + const typical = 28; + const periodOverdue = dayOfCycle > typical + 14;`, + ), + ).toContain("client-derived-verdict"); + }); + + it("catches the threshold LIFTED INTO A VARIABLE one line up", () => { + // The move that defeated a guard here before: no literal and no sum sit + // next to the comparison any more, and nothing is named suspiciously. + // The detector follows the local binding instead of reading the line. + expect( + kinds( + `const limit = 28; + const slack = 14; + const ceiling = limit + slack; + const dayOfCycle = 43; + const stale = dayOfCycle > ceiling;`, + ), + ).toContain("day-count-threshold"); + }); + + it("catches it lifted twice", () => { + expect( + kinds( + `const limit = 28; + const slack = 14; + const ceiling = limit + slack; + const bound = ceiling; + const dayOfCycle = 43; + const stale = dayOfCycle > bound;`, + ), + ).toContain("day-count-threshold"); + }); + + it("catches the sum written inline", () => { + expect( + kinds( + `const limit = 28; + const slack = 14; + const dayOfCycle = 43; + const stale = dayOfCycle > limit + slack;`, + ), + ).toContain("day-count-threshold"); + }); + + it("catches the shape with no suspicious name anywhere", () => { + // Nothing here is called grace, overdue, or late. The count compared + // against a sum is the whole tell. + expect( + kinds( + `function ring(dayOfCycle: number, a: number, b: number) { + return dayOfCycle >= a + b ? null : dayOfCycle; + }`, + ), + ).toContain("day-count-threshold"); + }); + + it("catches it with the operands swapped", () => { + expect( + kinds( + `function ring(dayOfCycle: number, a: number, b: number) { + return a + b < dayOfCycle; + }`, + ), + ).toContain("day-count-threshold"); + }); + + it("catches a class field carrying the window", () => { + expect(kinds(`class Wheel { private overdueGrace = 14; }`)).toContain( + "grace-constant", + ); + }); +}); + +describe("the guard stays quiet on innocent code", () => { + it("says nothing about reading the published state — the correct shape", () => { + expect( + kinds( + `const verdict = calendar.data?.verdict; + const periodOverdue = verdict?.state === "OVERDUE"; + const dayOfCycle = verdict?.dayOfCycle ?? null; + const overdueDays = verdict?.overdueDays ?? null;`, + ), + ).toEqual([]); + }); + + it("says nothing about drawing the ring from the published numbers", () => { + expect( + kinds( + `function ring(dayOfCycle: number | null, cycleLength: number | null) { + if (dayOfCycle == null || cycleLength == null || cycleLength <= 0) return 0; + return Math.min((dayOfCycle - 1) / cycleLength, 1); + }`, + ), + ).toEqual([]); + }); + + it("says nothing about a cycle-day count printed in copy", () => { + expect( + kinds( + `const label = dayOfCycle != null ? t("cycle.ring.dayOfCycle", { day: dayOfCycle }) : null;`, + ), + ).toEqual([]); + }); + + it("says nothing about an overdue medication dose", () => { + // The same English word, a different domain, no cycle vocabulary — the + // medication card decides its own overdue state and always has. + expect( + derivationFindings( + parse( + "medication-card.tsx", + `const overdue = dueAt != null && dueAt + graceMinutes < now; + const OVERDUE_GRACE_MINUTES = 30;`, + ), + ), + ).toEqual([]); + }); + + it("says nothing about an unrelated grace constant outside the cycle", () => { + expect( + derivationFindings( + parse("tour-launcher.tsx", `const POST_WIZARD_GRACE_MS = 4000;`), + ), + ).toEqual([]); + }); +}); diff --git a/src/__tests__/cycle-verdict-single-decider-guard.test.ts b/src/__tests__/cycle-verdict-single-decider-guard.test.ts new file mode 100644 index 000000000..4d39d23bc --- /dev/null +++ b/src/__tests__/cycle-verdict-single-decider-guard.test.ts @@ -0,0 +1,123 @@ +/** + * One decider for the cycle verdict. + * + * The server resolves the cycle day, the ring's arcs, the fertile-window state + * and the overdue judgement with its day count, and publishes them on + * `GET /api/cycle/calendar`. Client code renders that. It does not recompute + * any part of it, and it does not keep its own grace-period constant — while + * such a constant exists in client code it can be copied into another client, + * which is exactly how a cycle ring came to show a person a day their record + * did not hold. + * + * Two halves, because either one alone is satisfiable by broken code: + * nothing in the client tree may derive the verdict, AND the surfaces that + * show it must still be reading the published field. + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative as relativeTo } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + accessPaths, + derivationFindings, + parse, +} from "./helpers/cycle-verdict-derivation"; + +const ROOT = process.cwd(); + +/** Client-side code: the component tree, the hooks, and the app's pages. */ +const CLIENT_ROOTS = ["src/components", "src/hooks", "src/app"]; +const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + if (statSync(p).isDirectory()) { + walk(p, out); + } else if ( + (name.endsWith(".ts") || name.endsWith(".tsx")) && + !name.endsWith(".test.ts") && + !name.endsWith(".test.tsx") && + // Route handlers under `src/app/api` are the server; the verdict is + // resolved there on purpose. + !p.includes(`${join("src", "app", "api")}`) + ) { + out.push(relativeTo(ROOT, p)); + } + } + return out; +} + +function clientFiles(): string[] { + return CLIENT_ROOTS.flatMap((root) => walk(join(ROOT, root))); +} + +function parseFile(relative: string) { + return parse(relative, readFileSync(join(ROOT, relative), "utf8")); +} + +describe("the cycle verdict is decided in one place", () => { + it("finds no client file that re-derives it", () => { + const files = clientFiles(); + // A guard over an empty file list proves nothing. + expect(files.length).toBeGreaterThan(100); + + const findings = files.flatMap((relative) => + derivationFindings(parseFile(relative)), + ); + + expect( + findings.map((f) => `${f.file}:${f.line} ${f.kind} — ${f.where}`), + ).toEqual([]); + }); + + it("keeps the grace window out of client code entirely", () => { + // The specific constant that was copied. Named here so its return is loud + // rather than merely structural. + const offenders = clientFiles().filter((relative) => + readFileSync(join(ROOT, relative), "utf8").includes("OVERDUE_GRACE_DAYS"), + ); + expect(offenders).toEqual([]); + }); +}); + +describe("the surfaces that show the verdict actually read it", () => { + const CONSUMERS = [ + "src/components/cycle/cycle-view.tsx", + "src/components/cycle/cycle-ring-tile.tsx", + "src/components/cycle/cycle-insight-summary-card.tsx", + ]; + + it.each(CONSUMERS)("%s reads the published verdict", (relative) => { + const paths = accessPaths(parseFile(relative)); + const readsVerdict = [...paths].some((p) => p.endsWith(".verdict")); + expect(readsVerdict).toBe(true); + }); + + it("the temperature chart takes the cycle start from the verdict", () => { + // The BBT chart used to walk the calendar itself to find the cycle start, + // which was a second copy of the same walk. It now takes the resolved + // value as a prop, and the cycle page feeds it from the verdict. + const chart = readFileSync( + join(ROOT, "src/components/cycle/bbt-chart.tsx"), + "utf8", + ); + expect(chart).toContain("cycleStartDate"); + + const paths = accessPaths(parseFile("src/components/cycle/cycle-view.tsx")); + expect([...paths]).toContain("verdict.cycleStartDate"); + }); + + it("the calendar response type declares the verdict as always present", () => { + // An optional verdict is a plausible default that lets a client fall back + // to deriving its own. + const types = readFileSync( + join(ROOT, "src/components/cycle/types.ts"), + "utf8", + ); + expect(types).toContain("verdict: CycleVerdict;"); + expect(types).not.toContain("verdict?:"); + }); +}); diff --git a/src/__tests__/helpers/cycle-verdict-derivation.ts b/src/__tests__/helpers/cycle-verdict-derivation.ts new file mode 100644 index 000000000..c15f1af59 --- /dev/null +++ b/src/__tests__/helpers/cycle-verdict-derivation.ts @@ -0,0 +1,367 @@ +/** + * The detector behind the cycle-verdict single-decider guard. + * + * The cycle ring's day count, its phase arcs and the judgement that a period is + * late are resolved once, on the server, and published on + * `GET /api/cycle/calendar`. A second copy of that reasoning in client code is + * not a duplicate detail; it is a second answer about a person's body, and the + * last time it existed it froze someone's ring on a day their record did not + * hold. + * + * A text search would not hold this line. The grace window can be renamed, + * inlined as a bare `14`, or lifted into a variable one statement above the + * comparison — a defeat this repo has already watched happen to a different + * guard. So this reads the syntax tree and looks for three things: + * + * 1. a numeric constant named like a grace / overdue window; + * 2. a binding named like an overdue verdict whose value is COMPUTED from a + * comparison rather than read from the published `state`; + * 3. the threshold shape itself — a cycle-day count compared against a sum — + * whatever the pieces happen to be called. + * + * Rule 3 is what survives the rename: the constant can go anywhere, but the + * comparison has to exist somewhere for the client to reach a verdict, and it + * has the same shape wherever it hides. + */ +import ts from "typescript"; + +export type FindingKind = + "grace-constant" | "client-derived-verdict" | "day-count-threshold"; + +export interface Finding { + kind: FindingKind; + /** The binding or expression text that tripped the rule. */ + where: string; + file: string; + line: number; +} + +/** Names that read as a grace / overdue window. */ +const GRACE_NAME = /grace|overdue/i; +/** Names that read as "is the period late?". */ +const VERDICT_NAME = /overdue|periodlate|islate|isoverdue/i; +/** Names that read as a cycle-day count. */ +const DAY_COUNT_NAME = /day.?of.?cycle|cycleday|cyclerun|runlength/i; + +/** + * Markers that put a file in cycle territory. "Overdue" is a word other parts + * of the product use honestly — a medication dose is overdue, a screening is + * overdue — so the rules below only apply where the cycle's own vocabulary is + * present. A file cannot re-derive the cycle verdict without touching the + * calendar's types, its phase names, or the verdict's own fields, so nothing + * that could actually commit the defect escapes by renaming one identifier. + */ +const CYCLE_MARKERS = new Set([ + "CalendarDay", + "CalendarResponse", + "CyclePhase", + "CycleVerdict", + "CycleVerdictState", + "useCycleCalendar", + "dayOfCycle", + "cycleLength", + "cycleStartDate", + "typicalCycleLength", + "lutealPhaseLength", + "periodOverdue", + "MENSTRUAL", + "FOLLICULAR", + "OVULATORY", + "LUTEAL", +]); + +/** The published states. A comparison against one of these is a READ. */ +const PUBLISHED_STATES = new Set(["IN_CYCLE", "OVERDUE", "INSUFFICIENT_DATA"]); + +const COMPARISON_OPERATORS = new Set([ + ts.SyntaxKind.GreaterThanToken, + ts.SyntaxKind.GreaterThanEqualsToken, + ts.SyntaxKind.LessThanToken, + ts.SyntaxKind.LessThanEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, +]); + +const ARITHMETIC_OPERATORS = new Set([ + ts.SyntaxKind.PlusToken, + ts.SyntaxKind.MinusToken, +]); + +export function parse(fileName: string, source: string): ts.SourceFile { + return ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +/** Strip parentheses, `as`, `satisfies`, `!` so the shape underneath shows. */ +function unwrap(node: ts.Expression): ts.Expression { + let cur = node; + for (;;) { + if ( + ts.isParenthesizedExpression(cur) || + ts.isAsExpression(cur) || + ts.isSatisfiesExpression(cur) || + ts.isNonNullExpression(cur) + ) { + cur = cur.expression; + continue; + } + return cur; + } +} + +/** A plain number literal, with or without a leading minus. */ +function isNumericConstant(node: ts.Expression | undefined): boolean { + if (!node) return false; + const inner = unwrap(node); + if (ts.isNumericLiteral(inner)) return true; + return ( + ts.isPrefixUnaryExpression(inner) && + inner.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(inner.operand) + ); +} + +/** Every identifier-ish name appearing anywhere under `node`. */ +function namesUnder(node: ts.Node): string[] { + const out: string[] = []; + function visit(n: ts.Node): void { + if (ts.isIdentifier(n)) out.push(n.text); + ts.forEachChild(n, visit); + } + visit(node); + return out; +} + +/** Whether the expression compares against the PUBLISHED verdict state. */ +function readsPublishedState(node: ts.Node): boolean { + let found = false; + function visit(n: ts.Node): void { + if (found) return; + if (ts.isStringLiteral(n) && PUBLISHED_STATES.has(n.text)) { + found = true; + return; + } + if ( + (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) && + ts.isPropertyAccessExpression(n) && + n.name.text === "state" + ) { + found = true; + return; + } + ts.forEachChild(n, visit); + } + visit(node); + return found; +} + +/** Whether a comparison lives anywhere under `node`. */ +function containsComparison(node: ts.Node): boolean { + let found = false; + function visit(n: ts.Node): void { + if (found) return; + if ( + ts.isBinaryExpression(n) && + COMPARISON_OPERATORS.has(n.operatorToken.kind) + ) { + found = true; + return; + } + ts.forEachChild(n, visit); + } + visit(node); + return found; +} + +/** The name of a declaration, when it has a plain one. */ +function declaredName( + name: ts.Node | undefined, + file: ts.SourceFile, +): string | null { + if (!name) return null; + if (ts.isIdentifier(name)) return name.text; + if (ts.isStringLiteral(name)) return name.text; + if (ts.isComputedPropertyName(name)) return null; + if (ts.isPrivateIdentifier(name)) return name.text; + return name.getText(file); +} + +/** Every `const NAME = ` in the file, so one line up is not a hiding place. */ +function localBindings(file: ts.SourceFile): Map { + const bindings = new Map(); + function visit(node: ts.Node): void { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer + ) { + bindings.set(node.name.text, node.initializer); + } + ts.forEachChild(node, visit); + } + visit(file); + return bindings; +} + +/** + * Whether the expression is a sum, following local `const` bindings. + * + * `dayOfCycle > typical + grace` and + * `const ceiling = typical + grace; dayOfCycle > ceiling` are the same + * derivation written two ways, and lifting the sum into a variable one line + * above the comparison is precisely how a structural guard was talked around + * here before. Two hops is enough for any readable version of the move. + */ +function isSumLike( + node: ts.Expression, + bindings: Map, + seen: Set = new Set(), + depth = 0, +): boolean { + const inner = unwrap(node); + if ( + ts.isBinaryExpression(inner) && + ARITHMETIC_OPERATORS.has(inner.operatorToken.kind) + ) { + return true; + } + if (depth >= 3) return false; + if (ts.isIdentifier(inner) && !seen.has(inner.text)) { + const bound = bindings.get(inner.text); + if (bound) { + return isSumLike( + bound, + bindings, + new Set(seen).add(inner.text), + depth + 1, + ); + } + } + return false; +} + +/** Whether the file speaks the cycle's vocabulary at all. */ +export function isCycleContext(file: ts.SourceFile): boolean { + let found = false; + function visit(n: ts.Node): void { + if (found) return; + if (ts.isIdentifier(n) && CYCLE_MARKERS.has(n.text)) { + found = true; + return; + } + if (ts.isStringLiteral(n) && CYCLE_MARKERS.has(n.text)) { + found = true; + return; + } + ts.forEachChild(n, visit); + } + visit(file); + return found; +} + +/** + * Every way the cycle verdict could be decided again in the file. + * An empty array is the only acceptable result for client code. + */ +export function derivationFindings(file: ts.SourceFile): Finding[] { + const out: Finding[] = []; + if (!isCycleContext(file)) return out; + const bindings = localBindings(file); + + function record(kind: FindingKind, node: ts.Node, where: string): void { + const { line } = file.getLineAndCharacterOfPosition(node.getStart(file)); + out.push({ kind, where, file: file.fileName, line: line + 1 }); + } + + function checkBinding( + node: ts.Node, + nameNode: ts.Node | undefined, + initializer: ts.Expression | undefined, + ): void { + const name = declaredName(nameNode, file); + if (name === null || initializer === undefined) return; + + // 1. A grace / overdue window pinned to a number. + if (GRACE_NAME.test(name) && isNumericConstant(initializer)) { + record("grace-constant", node, name); + return; + } + + // 2. An overdue verdict COMPUTED here. Reading the published `state` is + // exactly what the client is supposed to do, so that shape is allowed. + if ( + VERDICT_NAME.test(name) && + containsComparison(initializer) && + !readsPublishedState(initializer) + ) { + record("client-derived-verdict", node, name); + } + } + + function visit(node: ts.Node): void { + if (ts.isVariableDeclaration(node)) { + checkBinding(node, node.name, node.initializer); + } else if (ts.isPropertyAssignment(node)) { + checkBinding(node, node.name, node.initializer); + } else if (ts.isPropertyDeclaration(node)) { + checkBinding(node, node.name, node.initializer); + } else if (ts.isParameter(node)) { + checkBinding(node, node.name, node.initializer); + } else if (ts.isEnumMember(node)) { + checkBinding(node, node.name, node.initializer); + } + + // 3. The threshold shape: a cycle-day count compared against a sum. The + // constant can be renamed, inlined, or lifted into a variable one line + // up; this comparison still has to exist for a client to decide. + if ( + ts.isBinaryExpression(node) && + COMPARISON_OPERATORS.has(node.operatorToken.kind) + ) { + const hasSum = [node.left, node.right].some((side) => + isSumLike(side, bindings), + ); + const mentionsDayCount = namesUnder(node).some((n) => + DAY_COUNT_NAME.test(n), + ); + if (hasSum && mentionsDayCount) { + record("day-count-threshold", node, node.getText(file).slice(0, 120)); + } + } + + ts.forEachChild(node, visit); + } + + visit(file); + return out; +} + +/** Every property-access path in the file, e.g. `calendar.data.verdict`. */ +export function accessPaths(file: ts.SourceFile): Set { + const paths = new Set(); + function pathOf(expression: ts.Expression): string | null { + const inner = unwrap(expression); + if (ts.isIdentifier(inner)) return inner.text; + if (ts.isPropertyAccessExpression(inner)) { + const base = pathOf(inner.expression); + return base === null ? null : `${base}.${inner.name.text}`; + } + return null; + } + function visit(node: ts.Node): void { + if (ts.isPropertyAccessExpression(node)) { + const p = pathOf(node); + if (p !== null) paths.add(p); + } + ts.forEachChild(node, visit); + } + visit(file); + return paths; +} diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts new file mode 100644 index 000000000..83ca9fb97 --- /dev/null +++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts @@ -0,0 +1,278 @@ +/** + * `GET /api/cycle/calendar` — the resolved verdict actually reaches a client. + * + * Both ends of a payload field can be written correctly and the wire can still + * carry nothing; this repo has shipped exactly that. So nothing here is + * hand-built downstream of the route: the test seeds rows, calls the REAL + * exported `GET`, reads the response body as text, parses it, and asserts on + * what came back. The engine, the calendar builder, the verdict resolver, the + * DTO mapping and the envelope all run for real. + * + * The scenario is the one that froze a cycle ring: several long observed + * cycles push the predicted next start weeks into the future, so every day of + * the gap carries a phase label and the raw count walks past the profile's + * typical length. The verdict must stop counting and say how late the period + * is — resolved against the USER's timezone day, not the process's. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + auditLog: { create: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() })); +// The cache persist is fire-and-forget and would reach the database. +vi.mock("@/lib/cycle/prediction-cache", () => ({ + persistPredictionCache: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { phaseForDate } from "@/lib/cycle/engine-adapter"; +import { resolveLuteal } from "@/lib/cycle/prediction"; +import { addDays } from "@/lib/cycle/day-math"; + +/** A user in UTC+14, so their calendar day runs ahead of the process's. */ +const AHEAD_TZ = "Pacific/Kiritimati"; + +function session(timezone: string) { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + gender: "FEMALE", + timezone, + locale: "en", + }, + }; +} + +function profile(overrides: Record = {}) { + return { + userId: "user-1", + goal: "GENERAL_HEALTH", + // What the person says their cycle usually is. The observed cycles below + // are much longer, which is what drives the prediction past today. + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + predictionEnabled: true, + rawChartMode: false, + secondarySymptom: "MUCUS", + cycleTrackingEnabled: true, + ...overrides, + }; +} + +/** Four period starts 60 days apart; the last one is still open. */ +const LONG_CYCLE_STARTS = [ + "2025-11-01", + "2025-12-31", + "2026-03-01", + "2026-04-30", +]; + +function cycleRows(starts: readonly string[]) { + return starts.map((startDate, i) => ({ + id: `cyc-${i}`, + userId: "user-1", + startDate, + endDate: null, + periodEndDate: null, + ovulationDate: null, + ovulationConfirmed: false, + isPredicted: false, + })); +} + +async function callCalendar(): Promise<{ + status: number; + body: Record; +}> { + const res = await GET(new NextRequest("http://localhost/api/cycle/calendar")); + // Read the WIRE, not the handler's return value. + const text = await res.text(); + return { status: res.status, body: JSON.parse(text) }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(session(AHEAD_TZ) as never); + vi.mocked(requireCycleEnabled).mockResolvedValue({ + enabled: true, + profile: profile(), + } as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows(LONG_CYCLE_STARTS) as never, + ); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("GET /api/cycle/calendar carries the resolved verdict", () => { + it("puts the whole verdict on the wire", async () => { + // 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + + const { status, body } = await callCalendar(); + expect(status).toBe(200); + + const data = body.data as Record; + expect(data).toHaveProperty("verdict"); + const verdict = data.verdict as Record; + + // Every field the contract promises, present on the response itself. + expect(Object.keys(verdict).sort()).toEqual([ + "cycleLength", + "cycleStartDate", + "dayOfCycle", + "daysUntilNext", + "fertileWindow", + "overdueDays", + "phase", + "spans", + "state", + ]); + }); + + it("resolves the overdue judgement in the USER's timezone, with the day count", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + // The user's day is 2026-06-11: 43 days into a cycle that opened on + // 2026-04-30, past their stated 28-day typical plus the grace window. The + // count stops and the delay is stated: 43 − 28 = 15. + expect(verdict.state).toBe("OVERDUE"); + expect(verdict.dayOfCycle).toBeNull(); + expect(verdict.phase).toBeNull(); + expect(verdict.overdueDays).toBe(15); + // The last logged period start survives the ceiling — the BBT chart scopes + // to it and the ring still draws four arcs. + expect(verdict.cycleStartDate).toBe("2026-04-30"); + expect(verdict.spans).toHaveLength(4); + }); + + it("reads one day earlier for the same instant in UTC — the zone decides", async () => { + // Same wall-clock instant, a user whose day has not rolled over yet. If + // the route resolved `today` from the process instead of the account, this + // case and the one above could not differ. + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + expect(verdict.state).toBe("IN_CYCLE"); + expect(verdict.dayOfCycle).toBe(42); + expect(verdict.overdueDays).toBeNull(); + }); + + it("agrees with the phase engine the coach read uses", async () => { + // Two independent server derivations of one number: the ring counts the + // labelled run, the coach/MCP block asks the phase engine directly. They + // must not be able to tell a person two different cycle days. + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-20T12:00:00Z")); + + const { body } = await callCalendar(); + const data = body.data as Record; + const verdict = data.verdict as Record; + const prediction = data.prediction as { nextPeriodStart: string } | null; + + expect(verdict.state).toBe("IN_CYCLE"); + + const fromEngine = phaseForDate( + "2026-05-20", + cycleRows(LONG_CYCLE_STARTS) as never, + prediction?.nextPeriodStart ?? null, + resolveLuteal(profile() as never), + ); + expect(verdict.dayOfCycle).toBe(fromEngine.dayOfCycle); + expect(verdict.phase).toBe(fromEngine.phase); + }); + + it("counts the days to the next predicted period", async () => { + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-20T12:00:00Z")); + + const { body } = await callCalendar(); + const data = body.data as Record; + const verdict = data.verdict as Record; + const prediction = data.prediction as { nextPeriodStart: string } | null; + + expect(prediction).not.toBeNull(); + const expected = Math.round( + (Date.parse(`${prediction!.nextPeriodStart}T12:00:00Z`) - + Date.parse("2026-05-20T12:00:00Z")) / + 86_400_000, + ); + expect(verdict.daysUntilNext).toBe(expected); + expect(verdict.daysUntilNext).toBeGreaterThan(0); + // Sanity: the predicted start really is ahead of today. + expect(prediction!.nextPeriodStart).toBe(addDays("2026-05-20", expected)); + }); + + it("says INSUFFICIENT_DATA when nothing has been logged", async () => { + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-20T12:00:00Z")); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + expect(verdict.state).toBe("INSUFFICIENT_DATA"); + expect(verdict.dayOfCycle).toBeNull(); + expect(verdict.cycleStartDate).toBeNull(); + expect(verdict.spans).toEqual([]); + }); +}); diff --git a/src/lib/cycle/__tests__/verdict.test.ts b/src/lib/cycle/__tests__/verdict.test.ts new file mode 100644 index 000000000..a7be69428 --- /dev/null +++ b/src/lib/cycle/__tests__/verdict.test.ts @@ -0,0 +1,258 @@ +/** + * The resolved cycle verdict. + * + * The load-bearing case is the overdue boundary. Beyond the profile's typical + * length plus the server's grace window the day count stops being an observed + * fact and the verdict must say so, with the number of days. One day either + * side of that line is the difference between a person reading "day 43" and + * reading "15 days later than usual", so both sides are pinned here, together + * with the day counts each side produces. + */ +import { describe, it, expect } from "vitest"; + +import { resolveCycleVerdict, type VerdictCalendarDay } from "../verdict"; +import type { CyclePhase } from "../types"; + +function day( + date: string, + phase: CyclePhase | null, + isFertileWindow = false, +): VerdictCalendarDay { + return { date, phase, isFertileWindow }; +} + +/** A run of 5 MENSTRUAL days then LUTEAL days, `n` long, ending at `today`. */ +function longRun( + n: number, + from = "2026-01-01", +): { + days: VerdictCalendarDay[]; + today: string; +} { + const start = Date.parse(`${from}T00:00:00Z`); + const days: VerdictCalendarDay[] = []; + for (let i = 0; i < n; i++) { + const date = new Date(start + i * 86_400_000).toISOString().slice(0, 10); + days.push(day(date, i < 5 ? "MENSTRUAL" : "LUTEAL")); + } + return { days, today: days[n - 1].date }; +} + +describe("resolveCycleVerdict — the overdue boundary", () => { + it("still counts on the last honest day", () => { + // Typical 28 + a 14-day grace window = 42. Day 42 is an observed fact. + const { days, today } = longRun(42); + const v = resolveCycleVerdict({ + days, + today, + profile: { typicalCycleLength: 28 }, + }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(42); + expect(v.overdueDays).toBeNull(); + }); + + it("stops counting one day later, and says how late", () => { + const { days, today } = longRun(43); + const v = resolveCycleVerdict({ + days, + today, + profile: { typicalCycleLength: 28 }, + }); + expect(v.state).toBe("OVERDUE"); + expect(v.dayOfCycle).toBeNull(); + expect(v.phase).toBeNull(); + // Measured against the typical length the person actually has, not against + // typical + grace: 43 days into a 28-day cycle is 15 days later than usual. + expect(v.overdueDays).toBe(15); + }); + + it("keeps growing the overdue count as the delay grows", () => { + const { days, today } = longRun(90); + const v = resolveCycleVerdict({ + days, + today, + profile: { typicalCycleLength: 28 }, + }); + expect(v.state).toBe("OVERDUE"); + expect(v.overdueDays).toBe(62); + // The ring still draws the canonical four-phase dial rather than nothing. + expect(v.spans).toHaveLength(4); + expect(v.cycleLength).toBe(28); + }); + + it("moves the boundary with the profile's own typical length", () => { + // A 60-day typical cycle keeps day 49 honest where a 28-day one would not. + const { days, today } = longRun(49); + const v = resolveCycleVerdict({ + days, + today, + profile: { typicalCycleLength: 60 }, + }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(49); + expect(v.overdueDays).toBeNull(); + }); + + it("keeps the cycle start even once the count stops", () => { + // The last logged period start is still a fact; the BBT chart scopes to it. + const { days, today } = longRun(60); + const v = resolveCycleVerdict({ + days, + today, + profile: { typicalCycleLength: 28 }, + }); + expect(v.state).toBe("OVERDUE"); + expect(v.cycleStartDate).toBe("2026-01-01"); + }); +}); + +describe("resolveCycleVerdict — what the record supports", () => { + it("says INSUFFICIENT_DATA when today carries no phase", () => { + const days = [day("2026-06-01", null), day("2026-06-02", null)]; + const v = resolveCycleVerdict({ days, today: "2026-06-02" }); + expect(v.state).toBe("INSUFFICIENT_DATA"); + expect(v.dayOfCycle).toBeNull(); + expect(v.phase).toBeNull(); + expect(v.cycleStartDate).toBeNull(); + expect(v.spans).toEqual([]); + }); + + it("says INSUFFICIENT_DATA when today is absent from the grid", () => { + const days = [day("2026-06-01", "MENSTRUAL")]; + const v = resolveCycleVerdict({ days, today: "2026-06-09" }); + expect(v.state).toBe("INSUFFICIENT_DATA"); + expect(v.dayOfCycle).toBeNull(); + }); + + it("counts the cycle day from the most recent menstrual start", () => { + const days = [ + day("2026-05-30", "LUTEAL"), + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "FOLLICULAR"), + day("2026-06-04", "FOLLICULAR"), + day("2026-06-05", "FOLLICULAR"), + ]; + const v = resolveCycleVerdict({ days, today: "2026-06-05" }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(5); + expect(v.phase).toBe("FOLLICULAR"); + expect(v.cycleStartDate).toBe("2026-06-01"); + }); + + it("builds observed phase arcs that sum to the whole ring", () => { + const days = [ + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "MENSTRUAL"), + day("2026-06-04", "FOLLICULAR"), + day("2026-06-05", "FOLLICULAR"), + day("2026-06-06", "OVULATORY"), + day("2026-06-07", "LUTEAL"), + day("2026-06-08", "LUTEAL"), + ]; + const v = resolveCycleVerdict({ days, today: "2026-06-05" }); + expect(v.spans.reduce((s, x) => s + x.fraction, 0)).toBeCloseTo(1, 5); + expect(v.spans.map((s) => s.phase).sort()).toEqual([ + "FOLLICULAR", + "LUTEAL", + "MENSTRUAL", + "OVULATORY", + ]); + expect(v.cycleLength).toBe(8); + }); + + it("draws the canonical four arcs for a low-data tracker", () => { + // A two-day first-ever run: the observed-share math would let MENSTRUAL + // fill nearly the whole circle, so the ring falls back to the profile's + // canonical proportions while the day count stays observed. + const days = [ + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + ]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-02", + profile: { + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + }, + }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(2); + expect(v.spans).toHaveLength(4); + expect(v.cycleLength).toBe(28); + for (const span of v.spans) { + expect(span.fraction).toBeGreaterThan(0); + expect(span.fraction).toBeLessThan(0.95); + } + }); +}); + +describe("resolveCycleVerdict — days until the next period", () => { + const days = [ + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "FOLLICULAR"), + ]; + + it("counts forward to the predicted start", () => { + const v = resolveCycleVerdict({ + days, + today: "2026-06-03", + nextPeriodStart: "2026-06-29", + }); + expect(v.daysUntilNext).toBe(26); + }); + + it("is null when no prediction ran", () => { + expect( + resolveCycleVerdict({ days, today: "2026-06-03" }).daysUntilNext, + ).toBeNull(); + }); + + it("is null once the predicted start is already past", () => { + // There is no "until" any more, and inventing a negative number would only + // invite a client to render it. + const v = resolveCycleVerdict({ + days, + today: "2026-06-03", + nextPeriodStart: "2026-06-01", + }); + expect(v.daysUntilNext).toBeNull(); + }); +}); + +describe("resolveCycleVerdict — the fertile window", () => { + it("reports the window it is given and whether today falls in it", () => { + const days = [ + day("2026-06-10", "FOLLICULAR"), + day("2026-06-11", "OVULATORY", true), + ]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-11", + fertileWindowStart: "2026-06-08", + fertileWindowEnd: "2026-06-13", + }); + expect(v.fertileWindow).toEqual({ + start: "2026-06-08", + end: "2026-06-13", + active: true, + }); + }); + + it("stays empty when the caller suppressed the window", () => { + // The route passes the ALREADY goal- and cold-start-gated values, so a + // suppressed window arrives here as nulls and cannot reappear. + const days = [day("2026-06-11", "OVULATORY", false)]; + const v = resolveCycleVerdict({ days, today: "2026-06-11" }); + expect(v.fertileWindow).toEqual({ + start: null, + end: null, + active: false, + }); + }); +});