From c65f122017d3caf98f83fd71aea0ab194723e06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 10:45:24 +0200 Subject: [PATCH] Prove the verdict reaches a client and that nothing recomputes it Three checks. The engine tests pin the overdue boundary from both sides: day 42 of a stated 28-day cycle still counts, day 43 stops counting and reports fifteen days later than usual. The round-trip test calls the real GET handler, reads the response body as text and parses it, so a field that both ends handle but the wire never carries fails here. It runs the same instant twice for two accounts in different timezones and gets two different answers, which is what the account timezone is for. It also checks the ring's cycle day against the phase engine the coach read uses, so the two cannot tell someone two different days. The guard reads the syntax tree of every file under components, hooks and app, and fails on a grace-period constant, on an overdue verdict computed rather than read, and on a cycle-day count compared against a sum. It follows local bindings, so lifting the threshold into a variable above the comparison does not hide it. A companion suite tries nine disguises against the same detector and pins five innocent shapes it must ignore, including an overdue medication dose. --- .../cycle-verdict-guard-defeat.test.ts | 193 +++++++++ ...cycle-verdict-single-decider-guard.test.ts | 123 ++++++ .../helpers/cycle-verdict-derivation.ts | 367 ++++++++++++++++++ .../__tests__/verdict-round-trip.test.ts | 278 +++++++++++++ src/lib/cycle/__tests__/verdict.test.ts | 258 ++++++++++++ 5 files changed, 1219 insertions(+) create mode 100644 src/__tests__/cycle-verdict-guard-defeat.test.ts create mode 100644 src/__tests__/cycle-verdict-single-decider-guard.test.ts create mode 100644 src/__tests__/helpers/cycle-verdict-derivation.ts create mode 100644 src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts create mode 100644 src/lib/cycle/__tests__/verdict.test.ts 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, + }); + }); +});