mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(cycle): carry every logged day entry on the calendar read
The calendar grid carried flow, symptoms, BBT, the ovulation test and the mucus reading, and dropped the rest of the day log: intercourse, spotting, the pregnancy and progesterone tests, contraception and whether a note exists. The published contract also promised the three cervix signs while the route never sent them. The intent fields resolve through the same reader the day-log read uses, so an encrypted envelope reads the same on both, and the grid is served under the same cycle grant as the day log. Additive fields; the contract test pins the wire keys to the published schema.
This commit is contained in:
1 parent
d81518b5bf
commit
adec5bd045
7 files changed
+431
-31
No files matched your search
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* `GET /api/cycle/calendar` — what a person logged on a day reaches the grid.
|
||||
*
|
||||
* The calendar used to carry flow, symptoms, BBT, the ovulation test and the
|
||||
* mucus reading, and nothing else from the day log. Intercourse, spotting, the
|
||||
* pregnancy and progesterone tests, contraception, the cervix signs and the
|
||||
* presence of a note were all dropped between the row and the wire, so a day
|
||||
* with only one of those logged looked exactly like a day with nothing logged.
|
||||
*
|
||||
* Same harness as the verdict round trip: the real exported `GET`, the real
|
||||
* builder, the real DTO mapping, the response read back as text. Two ends are
|
||||
* pinned — what the route asks Prisma for (a field that is not selected never
|
||||
* reaches the builder) and what comes back on the wire.
|
||||
*
|
||||
* The intent fields can sit in the encrypted envelope instead of the plaintext
|
||||
* columns (`sensitiveCategoryEncryption`), so one case writes them there and
|
||||
* expects the grid to read them just as the day-log read does.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
cyclePrediction: { findUnique: vi.fn(), upsert: 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() }));
|
||||
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 { encrypt } from "@/lib/crypto";
|
||||
import { cycleCalendarDayDto } from "@/lib/openapi/routes/cycle";
|
||||
|
||||
function session() {
|
||||
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: "UTC",
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function profile() {
|
||||
return {
|
||||
userId: "user-1",
|
||||
goal: "GENERAL_HEALTH",
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
secondarySymptom: "MUCUS",
|
||||
cycleTrackingEnabled: true,
|
||||
sensitiveCategoryEncryption: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** A day-log row as Prisma returns it for the route's select. */
|
||||
function logRow(date: string, over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
date,
|
||||
flow: null,
|
||||
intermenstrualBleeding: false,
|
||||
basalBodyTempC: null,
|
||||
temperatureExcluded: false,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
sensitiveEncrypted: null,
|
||||
notesEncrypted: null,
|
||||
_count: { symptomLinks: 0 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function callCalendar(): Promise<Record<string, unknown>[]> {
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/cycle/calendar?from=2026-09-01&to=2026-09-30",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = JSON.parse(await res.text());
|
||||
return body.data.days as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function day(days: Record<string, unknown>[], date: string) {
|
||||
const found = days.find((d) => d.date === date);
|
||||
if (!found) throw new Error(`no grid day ${date}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-09-25T12:00:00Z"));
|
||||
vi.mocked(getSession).mockResolvedValue(session() as never);
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
profile: profile(),
|
||||
} as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "cyc-1",
|
||||
userId: "user-1",
|
||||
startDate: "2025-12-29",
|
||||
endDate: null,
|
||||
periodEndDate: null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
isPredicted: false,
|
||||
},
|
||||
] 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 every per-day entry", () => {
|
||||
it("asks Prisma for the fields the grid now shows", async () => {
|
||||
await callCalendar();
|
||||
const args = vi.mocked(prisma.cycleDayLog.findMany).mock.calls[0][0] as {
|
||||
select: Record<string, unknown>;
|
||||
};
|
||||
expect(args.select).toMatchObject({
|
||||
intermenstrualBleeding: true,
|
||||
sexualActivity: true,
|
||||
pregnancyTest: true,
|
||||
progesteroneTest: true,
|
||||
contraceptive: true,
|
||||
sensitiveEncrypted: true,
|
||||
notesEncrypted: true,
|
||||
cervixPosition: true,
|
||||
cervixFirmness: true,
|
||||
cervixOpening: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a day with intercourse logged, and only that day", async () => {
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
logRow("2026-09-05", { sexualActivity: true }),
|
||||
] as never);
|
||||
|
||||
const days = await callCalendar();
|
||||
expect(day(days, "2026-09-05").sexualActivity).toBe(true);
|
||||
expect(day(days, "2026-09-04").sexualActivity).toBe(false);
|
||||
expect(day(days, "2026-09-06").sexualActivity).toBe(false);
|
||||
});
|
||||
|
||||
it("reads intercourse and the tests from the encrypted envelope", async () => {
|
||||
// Encryption ON at write: the plaintext columns are NULL/false and the
|
||||
// envelope carries the values. The grid must read what the day log reads.
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
logRow("2026-09-05", {
|
||||
sexualActivity: false,
|
||||
sensitiveEncrypted: encrypt(
|
||||
JSON.stringify({
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: "NEGATIVE",
|
||||
progesteroneTest: "POSITIVE",
|
||||
contraceptive: "ORAL",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const d = day(await callCalendar(), "2026-09-05");
|
||||
expect(d.sexualActivity).toBe(true);
|
||||
expect(d.pregnancyTest).toBe("NEGATIVE");
|
||||
expect(d.progesteroneTest).toBe("POSITIVE");
|
||||
expect(d.contraceptive).toBe("ORAL");
|
||||
});
|
||||
|
||||
it("an undecryptable envelope reads as nothing logged, never a 500", async () => {
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
logRow("2026-09-05", { sensitiveEncrypted: "v1:not-a-real-envelope" }),
|
||||
] as never);
|
||||
|
||||
const d = day(await callCalendar(), "2026-09-05");
|
||||
expect(d.sexualActivity).toBe(false);
|
||||
expect(d.pregnancyTest).toBeNull();
|
||||
});
|
||||
|
||||
it("carries spotting, the cervix signs and whether a note exists", async () => {
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
logRow("2026-09-10", {
|
||||
intermenstrualBleeding: true,
|
||||
cervixPosition: "HIGH",
|
||||
cervixFirmness: "SOFT",
|
||||
cervixOpening: "OPEN",
|
||||
notesEncrypted: encrypt("a note"),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const days = await callCalendar();
|
||||
const d = day(days, "2026-09-10");
|
||||
expect(d.intermenstrualBleeding).toBe(true);
|
||||
expect(d.cervixPosition).toBe("HIGH");
|
||||
expect(d.cervixFirmness).toBe("SOFT");
|
||||
expect(d.cervixOpening).toBe("OPEN");
|
||||
expect(d.hasNote).toBe(true);
|
||||
// The note's text never rides the grid, only its presence.
|
||||
expect(JSON.stringify(d)).not.toContain("a note");
|
||||
expect(day(days, "2026-09-11").hasNote).toBe(false);
|
||||
});
|
||||
|
||||
it("every grid day carries exactly the fields the published contract names", async () => {
|
||||
// The contract promised the cervix signs while the route never sent them.
|
||||
// Pin the two against each other so neither can drift alone again.
|
||||
const days = await callCalendar();
|
||||
const contractKeys = Object.keys(cycleCalendarDayDto.shape).sort();
|
||||
expect(Object.keys(days[0]).sort()).toEqual(contractKeys);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
toCyclePredictionDTO,
|
||||
goalAllowsFertileWindow,
|
||||
cycleDisclaimerKey,
|
||||
resolveSensitiveFields,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
@@ -115,6 +116,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
cervixPosition: true,
|
||||
cervixFirmness: true,
|
||||
cervixOpening: true,
|
||||
intermenstrualBleeding: true,
|
||||
// The intent fields and their encrypted envelope, resolved below
|
||||
// through the same reader the day-log route uses. Same `cycle` grant
|
||||
// as that route (see sharing-surface-guard), so the grid shows a
|
||||
// delegate nothing the day log would not.
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: true,
|
||||
progesteroneTest: true,
|
||||
contraceptive: true,
|
||||
sensitiveEncrypted: true,
|
||||
// Selected for presence only; the text is never decrypted here.
|
||||
notesEncrypted: true,
|
||||
_count: { select: { symptomLinks: true } },
|
||||
},
|
||||
}),
|
||||
@@ -141,18 +155,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => ({
|
||||
date: l.date,
|
||||
flow: l.flow,
|
||||
basalBodyTempC: l.basalBodyTempC,
|
||||
temperatureExcluded: l.temperatureExcluded,
|
||||
ovulationTest: l.ovulationTest,
|
||||
cervicalMucus: l.cervicalMucus,
|
||||
cervixPosition: l.cervixPosition,
|
||||
cervixFirmness: l.cervixFirmness,
|
||||
cervixOpening: l.cervixOpening,
|
||||
hasSymptoms: l._count.symptomLinks > 0,
|
||||
}));
|
||||
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => {
|
||||
const sensitive = resolveSensitiveFields(l);
|
||||
return {
|
||||
date: l.date,
|
||||
flow: l.flow,
|
||||
basalBodyTempC: l.basalBodyTempC,
|
||||
temperatureExcluded: l.temperatureExcluded,
|
||||
ovulationTest: l.ovulationTest,
|
||||
cervicalMucus: l.cervicalMucus,
|
||||
cervixPosition: l.cervixPosition,
|
||||
cervixFirmness: l.cervixFirmness,
|
||||
cervixOpening: l.cervixOpening,
|
||||
intermenstrualBleeding: l.intermenstrualBleeding,
|
||||
sexualActivity: sensitive.sexualActivity,
|
||||
pregnancyTest: sensitive.pregnancyTest,
|
||||
progesteroneTest: sensitive.progesteroneTest,
|
||||
contraceptive: sensitive.contraceptive,
|
||||
hasSymptoms: l._count.symptomLinks > 0,
|
||||
hasNote: l.notesEncrypted != null && l.notesEncrypted !== "",
|
||||
};
|
||||
});
|
||||
|
||||
const nights = nightlyTemps.map((m) => ({
|
||||
date: moodDateKey(m.measuredAt, tz),
|
||||
|
||||
Reference in new issue
Block a user