mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'fix/v1391-cycle-and-links' into release/v1.39.1
This commit is contained in:
commit
ee23409b77
37 files changed
+1236
-101
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