mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The write-level row listed "a vaccination dose and the booster reminder it suggests" three paragraphs under a section table that puts the vaccination record in Health background. Read together, that tells a self-hoster a health-background WRITE grant can plan a booster — which the route beside it now refuses, because the reminder it mints lives under Readings. The row lists the dose alone, and the exceptions paragraph, which is where a reader looks for exactly this, carries the two-section case and says the dose logs either way. With it, the small things the same pass turned up: The cycle-insights fixture asserted a property it could not show. Its docblock claims the cycle-native answer survives the fence, and it seeded no symptom at all, so `symptomPatterns` was empty in every leg and a regression that emptied it along with the foreign columns would have passed green. One catalogue symptom on the luteal days, asserted on the owner leg and on the cycle-only one. The immunization rows matched on a bare `role="button"`, which passes vacuously the day the card becomes a real button and fails for the wrong reason if anything else in the subtree grows one. They carry the same stable marker the visit card took. `/api/cycle/calendar` reads the same wrist-temperature channel the crosstab does and is deliberately not fenced, and it was the only one of the pair with nothing saying why: no value from it reaches the response. The encrypted export's new 403 documented the switch refusal and not the narrow-scope one it answers first, unlike the two settings operations beside it. Both sheets say at the top that their Save and Delete are gated at the caller, so a third mount reads the constraint before it violates it. And one docblock line ran past the wrap.
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
/**
|
|
* What a cycle-only delegate learns from `GET /api/cycle/insights`.
|
|
*
|
|
* The route is declared `("read", "cycle")`, and everything about the phase
|
|
* labels belongs there. Its outcome columns do not: `lutealAvg`,
|
|
* `follicularAvg` and `delta` carry resting heart rate, HRV, sleep, steps,
|
|
* weight, temperature, glucose and the mood score in display units, and those
|
|
* are `measurements` and `mind` data reached through a cycle grant.
|
|
*
|
|
* So two properties, both about somebody holding `["cycle"]` and nothing else:
|
|
*
|
|
* 1. **No foreign figure crosses the seam.** No row whose `metricKey` names
|
|
* a measurement channel, no mood row, and no lagged discovery built out
|
|
* of either.
|
|
* 2. **The cycle-native answer still arrives.** The symptom-by-phase
|
|
* patterns are cycle data and stay, so the fix is a fence rather than a
|
|
* blanket refusal.
|
|
*
|
|
* The unscoped grant is asserted in the same fixture, because a change that
|
|
* hid the figures from everyone would satisfy the first property and break
|
|
* the feature.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import type { ShareDomain } from "@/lib/sharing/scope";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => cookieJar.set(name, value),
|
|
delete: (name: string) => cookieJar.delete(name),
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const OWNER_ID = "cycle-insights-owner";
|
|
const DELEGATE_ID = "cycle-insights-delegate";
|
|
|
|
/** Cycle geometry the fixture logs, chosen so both phases clear the day floor. */
|
|
const CYCLE_LENGTH = 28;
|
|
const PERIOD_LENGTH = 5;
|
|
const OBSERVED_CYCLES = 5;
|
|
|
|
function isoDay(offsetDays: number): string {
|
|
const d = new Date(Date.now() + offsetDays * 24 * 60 * 60 * 1000);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function atNoon(day: string): Date {
|
|
return new Date(`${day}T12:00:00Z`);
|
|
}
|
|
|
|
async function signIn(userId: string) {
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return session;
|
|
}
|
|
|
|
/** Sign in as the delegate and switch into the owner's record. */
|
|
async function switchInto(scope: ShareDomain[] | null) {
|
|
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
|
const invited = await inviteGrant({
|
|
grantorId: OWNER_ID,
|
|
granteeId: DELEGATE_ID,
|
|
access: "READ",
|
|
scope,
|
|
});
|
|
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
|
|
const session = await signIn(DELEGATE_ID);
|
|
await switchSessionTo(session.id, OWNER_ID);
|
|
}
|
|
|
|
/**
|
|
* Five observed cycles, then a weight and a mood value on every labelled day
|
|
* whose size depends on the phase.
|
|
*
|
|
* The separation is deliberately huge (80 kg vs 70 kg, mood 2 vs 4) so the
|
|
* Welch + Benjamini-Hochberg gate the route runs cannot be the reason a row is
|
|
* missing: with this fixture a missing row means the fence removed it.
|
|
*/
|
|
async function seedRecord() {
|
|
const prisma = getPrismaClient();
|
|
for (const [id, name] of [
|
|
[OWNER_ID, "owner"],
|
|
[DELEGATE_ID, "delegate"],
|
|
] as const) {
|
|
await prisma.user.create({
|
|
data: {
|
|
id,
|
|
username: `cycle-insights-${name}`,
|
|
email: `cycle-insights-${name}@example.test`,
|
|
gender: "FEMALE",
|
|
timezone: "UTC",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Prediction off, so the phase map the route builds is the one this fixture
|
|
// can reproduce from the logged cycles alone.
|
|
await prisma.cycleProfile.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
cycleTrackingEnabled: true,
|
|
predictionEnabled: false,
|
|
lutealPhaseLength: 14,
|
|
},
|
|
});
|
|
|
|
const starts: string[] = [];
|
|
for (let i = OBSERVED_CYCLES; i >= 1; i--) {
|
|
starts.push(isoDay(-i * CYCLE_LENGTH));
|
|
}
|
|
for (const [index, startDate] of starts.entries()) {
|
|
const next = starts[index + 1] ?? null;
|
|
await prisma.menstrualCycle.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
startDate,
|
|
endDate: next
|
|
? isoDay(-(OBSERVED_CYCLES - index) * CYCLE_LENGTH - 1 + CYCLE_LENGTH)
|
|
: null,
|
|
periodEndDate: isoDay(
|
|
-(OBSERVED_CYCLES - index) * CYCLE_LENGTH + PERIOD_LENGTH - 1,
|
|
),
|
|
ovulationDate: isoDay(
|
|
-(OBSERVED_CYCLES - index) * CYCLE_LENGTH + CYCLE_LENGTH - 15,
|
|
),
|
|
tz: "UTC",
|
|
},
|
|
});
|
|
}
|
|
|
|
const { buildPhaseDayMap } = await import("@/lib/cycle/engine-adapter");
|
|
const cycles = await prisma.menstrualCycle.findMany({
|
|
where: { userId: OWNER_ID },
|
|
orderBy: { startDate: "asc" },
|
|
});
|
|
const today = isoDay(0);
|
|
const phaseByDay = buildPhaseDayMap(
|
|
cycles,
|
|
null,
|
|
14,
|
|
isoDay(-OBSERVED_CYCLES * CYCLE_LENGTH),
|
|
today,
|
|
today,
|
|
);
|
|
|
|
// One catalogue symptom, logged on enough phase-labelled days to clear the
|
|
// pattern floor. Without it `symptomPatterns` is empty in every leg and the
|
|
// "the cycle-native answer still arrives" property could not be shown even
|
|
// by a leg that asserted it.
|
|
//
|
|
// Upserted rather than created: the symptom catalogue is seeded by migration
|
|
// and survives the per-test truncation, so a plain create passes on the
|
|
// first leg and collides on the second.
|
|
const category = await prisma.cycleSymptomCategory.upsert({
|
|
where: { key: "cycle-insights-cat" },
|
|
update: {},
|
|
create: { key: "cycle-insights-cat", labelKey: "cycle.category.test" },
|
|
});
|
|
const symptom = await prisma.cycleSymptom.upsert({
|
|
where: { key: "cycle-insights-cramps" },
|
|
update: {},
|
|
create: {
|
|
categoryId: category.id,
|
|
key: "cycle-insights-cramps",
|
|
labelKey: "cycle.symptom.cramps",
|
|
},
|
|
});
|
|
|
|
let luteal = 0;
|
|
let follicular = 0;
|
|
for (const [day, phase] of phaseByDay) {
|
|
if (phase !== "LUTEAL" && phase !== "FOLLICULAR") continue;
|
|
const high = phase === "LUTEAL";
|
|
if (high) luteal++;
|
|
else follicular++;
|
|
// A little jitter in both channels, so the Welch test has a variance to
|
|
// work with and the row is not dropped for a reason the fence did not
|
|
// cause.
|
|
const jitter = ((luteal + follicular) % 3) * 0.1;
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "WEIGHT",
|
|
value: (high ? 80 : 70) + jitter,
|
|
unit: "kg",
|
|
measuredAt: atNoon(day),
|
|
},
|
|
});
|
|
await prisma.moodEntry.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
mood: high ? "SCHLECHT" : "GUT",
|
|
score: (high ? 1 : 4) + ((luteal + follicular) % 2),
|
|
date: day,
|
|
moodLoggedAt: atNoon(day),
|
|
tz: "UTC",
|
|
},
|
|
});
|
|
// Cycle-native data, logged on the luteal days only so the pattern has a
|
|
// dominant phase to report.
|
|
if (high) {
|
|
await prisma.cycleDayLog.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
date: day,
|
|
tz: "UTC",
|
|
symptomLinks: { create: [{ symptomId: symptom.id }] },
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
expect(
|
|
luteal,
|
|
"the fixture labels enough luteal days to clear the crosstab floor",
|
|
).toBeGreaterThanOrEqual(5);
|
|
expect(
|
|
follicular,
|
|
"the fixture labels enough follicular days to clear the crosstab floor",
|
|
).toBeGreaterThanOrEqual(5);
|
|
}
|
|
|
|
interface InsightsBody {
|
|
rows: Array<{ metricKey: string }>;
|
|
headline: { metricKey: string } | null;
|
|
lagged: { discovered: unknown[] };
|
|
symptomPatterns: Array<{ symptomKey: string; topPhase: string }>;
|
|
}
|
|
|
|
async function readInsights(): Promise<InsightsBody> {
|
|
const { GET } = await import("@/app/api/cycle/insights/route");
|
|
// The handler declares no parameters; the wrapper still reads the request
|
|
// off its first argument, and the method it carries is what the record
|
|
// resolver escalates on.
|
|
const handler = GET as unknown as (request: Request) => Promise<Response>;
|
|
const res = await handler(new Request("http://localhost/api/cycle/insights"));
|
|
expect(res.status).toBe(200);
|
|
const parsed = (await res.json()) as { data: InsightsBody };
|
|
return parsed.data;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await seedRecord();
|
|
});
|
|
|
|
describe("cycle insights fence the sections it reads across", () => {
|
|
it("gives the owner the measurement and the mood contrast", async () => {
|
|
await signIn(OWNER_ID);
|
|
const body = await readInsights();
|
|
|
|
const keys = body.rows.map((row) => row.metricKey);
|
|
expect(keys, "the weight contrast is the fixture's whole point").toContain(
|
|
"weight",
|
|
);
|
|
expect(keys, "and the mood contrast beside it").toContain("mood");
|
|
expect(
|
|
body.symptomPatterns.map((row) => row.symptomKey),
|
|
"and the cycle-native patterns the fence must not touch",
|
|
).toContain("cycle-insights-cramps");
|
|
});
|
|
|
|
it("gives an entire-record delegate the same figures", async () => {
|
|
await switchInto(null);
|
|
const body = await readInsights();
|
|
|
|
const keys = body.rows.map((row) => row.metricKey);
|
|
expect(keys).toContain("weight");
|
|
expect(keys).toContain("mood");
|
|
});
|
|
|
|
it("gives a cycle-only delegate no measurement and no mood figure", async () => {
|
|
await switchInto(["cycle"]);
|
|
const body = await readInsights();
|
|
|
|
expect(
|
|
body.rows,
|
|
"every crosstab row is a measurement or a mood column",
|
|
).toHaveLength(0);
|
|
expect(body.headline).toBeNull();
|
|
expect(
|
|
body.lagged.discovered,
|
|
"the lagged matrix is built from the same two sections",
|
|
).toHaveLength(0);
|
|
// The other half of the property, and the one a blanket refusal would
|
|
// break: the symptom-by-phase patterns are cycle data and the grant opens
|
|
// cycle, so they arrive in full.
|
|
expect(
|
|
body.symptomPatterns.map((row) => row.symptomKey),
|
|
"the cycle-native answer still arrives",
|
|
).toContain("cycle-insights-cramps");
|
|
});
|
|
|
|
it("gives a cycle+mind delegate the mood figure and not the weight", async () => {
|
|
await switchInto(["cycle", "mind"]);
|
|
const body = await readInsights();
|
|
|
|
const keys = body.rows.map((row) => row.metricKey);
|
|
expect(
|
|
keys,
|
|
"mind is held, so the mood column is within the grant",
|
|
).toContain("mood");
|
|
expect(keys, "measurements is not held").not.toContain("weight");
|
|
});
|
|
});
|