mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(cycle): fence the phase crosstab's outcome columns to the sections they belong to
`GET /api/cycle/insights` is declared `("read", "cycle")`, which is right for
the phase labels and wrong for what it compares them against. The crosstab rows
carry `lutealAvg`, `follicularAvg` and `delta` in display units for resting
heart rate, HRV, sleep duration, steps, weight, body/wrist/skin temperature and
blood glucose, plus the mood score read straight off `MoodEntry` — measurement
and mind data, published in full to a grant scoped to the cycle alone, with the
lagged discovery's narrated interpretation on top.
The visits service already had the right shape for this: a predicate over the
grant's sections, asked before the foreign rows are read. Both sections are now
fenced at the query rather than in the response, so a caller outside them never
loads the values in the first place and the rows read exactly as they do for a
record with nothing logged. The wrist-temperature read that sharpens the
prediction stays: it never reaches the response.
The predicate had two verbatim copies, one in the visits service and one in the
vaccination service, with a comment on the second saying a second spelling would
be a second thing to keep in step. It is one module now, `sharing/acting-domains`,
and the third consumer imports it rather than a feature service.
`cycle-insights-scoped-delegate.test.ts` drives four grants against a fixture
whose separation is large enough that the FDR gate cannot be the reason a row is
missing: the owner and an entire-record delegate get both figures, a cycle-only
delegate gets neither and no lagged row, and a cycle+mind delegate gets the mood
column and not the weight. Both scoped legs fail against the previous handler.
This commit is contained in:
1 parent
036720bcfb
commit
e645921852
12 files changed
+375
-88
No files matched your search
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -50,7 +51,7 @@ const WINDOW_DAYS = 365;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
const { user, grantId } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -68,6 +69,19 @@ export const GET = apiHandler(async () => {
|
||||
const today = moodDateKey(new Date(), tz);
|
||||
const from = addDays(today, -WINDOW_DAYS);
|
||||
|
||||
// The crosstab is a cycle surface that reads two other sections. The phase
|
||||
// labels are cycle data; the outcome columns are not. `lutealAvg`,
|
||||
// `follicularAvg` and `delta` ship resting heart rate, HRV, sleep duration,
|
||||
// steps, weight, temperature, glucose and the mood score in display units,
|
||||
// and a grant scoped to the cycle alone was never consent for any of them.
|
||||
// So the two foreign channels are fenced at the query, not at the response:
|
||||
// a section the caller is outside of is never read, so nothing downstream
|
||||
// can leak it back — the rows simply do not exist, exactly as they do not
|
||||
// for a record with no measurements.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const seesMeasurements = visible("measurements");
|
||||
const seesMind = visible("mind");
|
||||
|
||||
const [
|
||||
cycles,
|
||||
dayLogRows,
|
||||
@@ -116,25 +130,28 @@ export const GET = apiHandler(async () => {
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
// The outcome metrics the phase contrast compares — soft-delete-scoped,
|
||||
// canonical-source deduped per day inside `metricDayMap`.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
// canonical-source deduped per day inside `metricDayMap`. Skipped whole
|
||||
// for a caller outside `measurements`.
|
||||
seesMeasurements
|
||||
? prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
})
|
||||
: [],
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
@@ -142,15 +159,18 @@ export const GET = apiHandler(async () => {
|
||||
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
|
||||
// Measurement row, so read it here and inject it into the crosstab as a
|
||||
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
// Skipped whole for a caller outside `mind`.
|
||||
seesMind
|
||||
? prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
// The latest open cycle runs to the predicted next-period start so the
|
||||
@@ -239,6 +259,8 @@ export const GET = apiHandler(async () => {
|
||||
lagged_discovered: lagged.discovered.length,
|
||||
lagged_pairs_tested: lagged.pairsTested,
|
||||
symptom_patterns: symptomPatterns.length,
|
||||
measurements_visible: seesMeasurements,
|
||||
mind_visible: seesMind,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -27,10 +27,8 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { encounterLinkSchema } from "@/lib/validations/encounters";
|
||||
import { linkTargets, unlinkTargets } from "@/lib/links";
|
||||
import {
|
||||
actingDomainVisibility,
|
||||
loadEncounterLinks,
|
||||
} from "@/lib/encounters/service";
|
||||
import { loadEncounterLinks } from "@/lib/encounters/service";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
|
||||
import { toEncounterDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
loadEncounterLinks,
|
||||
resolveOwnerNotificationContext,
|
||||
shouldHaveReminder,
|
||||
} from "@/lib/encounters/service";
|
||||
import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
|
||||
import { toEncounterDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyEncounterLinks,
|
||||
closeCheckupForVisit,
|
||||
findOwnAppointmentReminderId,
|
||||
@@ -54,6 +53,7 @@ import {
|
||||
type RestoreSkipLog,
|
||||
} from "@/lib/export/restore-skips";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
|
||||
import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyEncounterLinks,
|
||||
loadEncounterLinksForMany,
|
||||
closeCheckupForVisit,
|
||||
@@ -50,6 +49,7 @@ import {
|
||||
summarizeRestoreSkips,
|
||||
type RestoreSkipLog,
|
||||
} from "@/lib/export/restore-skips";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user, grantId } = await requireRecordAuth("read", "profile");
|
||||
|
||||
@@ -26,10 +26,8 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { vaccinationLinkSchema } from "@/lib/validations/vaccinations";
|
||||
import { linkTargets, unlinkTargets } from "@/lib/links";
|
||||
import {
|
||||
actingDomainVisibility,
|
||||
loadVaccinationDocuments,
|
||||
} from "@/lib/vaccinations/service";
|
||||
import { loadVaccinationDocuments } from "@/lib/vaccinations/service";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { toVaccinationDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
VACCINATION_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
loadVaccinationDocuments,
|
||||
resolveSeriesFor,
|
||||
} from "@/lib/vaccinations/service";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations";
|
||||
import { toVaccinationDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
VACCINATION_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyVaccinationLinks,
|
||||
loadVaccinationDocuments,
|
||||
resolveOwnedEncounter,
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
resolveSeriesFor,
|
||||
} from "@/lib/vaccinations/service";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
replaceTargets,
|
||||
type LinkTargetKind,
|
||||
} from "@/lib/links";
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
@@ -59,27 +58,6 @@ export const ENCOUNTER_INCLUDE = {
|
||||
reminder: { select: { nextDueAt: true } },
|
||||
} as const satisfies Prisma.EncounterInclude;
|
||||
|
||||
/**
|
||||
* Which sections of the record the caller is actually inside.
|
||||
*
|
||||
* Returns a predicate rather than a set so the owner path costs nothing: with
|
||||
* no grant there is nothing to look up and every domain is open. A grant that
|
||||
* vanished between the auth check and here answers "no" to everything, which
|
||||
* is the fail-closed direction.
|
||||
*/
|
||||
export async function actingDomainVisibility(
|
||||
tx: Prisma.TransactionClient,
|
||||
grantId: string | null,
|
||||
): Promise<(domain: ShareDomain) => boolean> {
|
||||
if (grantId === null) return () => true;
|
||||
const grant = await tx.accountGrant.findUnique({
|
||||
where: { id: grantId },
|
||||
select: { scopeJson: true },
|
||||
});
|
||||
if (!grant) return () => false;
|
||||
return (domain) => grantCoversDomain(grant, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which section of the record each link family's TARGET belongs to.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Which sections of the record the caller is actually inside.
|
||||
*
|
||||
* Lives here rather than beside one feature because three unrelated seams ask
|
||||
* it — a visit's link labels, a vaccination's, and the cycle crosstab's
|
||||
* outcome columns — and a route that reads across its own declared section is
|
||||
* a shape the product will keep growing. Two verbatim copies of it already
|
||||
* existed in the visits and vaccination services; a third would have been the
|
||||
* one that drifted.
|
||||
*
|
||||
* Returns a predicate rather than a set so the owner path costs nothing: with
|
||||
* no grant there is nothing to look up and every domain is open. A grant that
|
||||
* vanished between the auth check and here answers "no" to everything, which
|
||||
* is the fail-closed direction.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
export async function actingDomainVisibility(
|
||||
tx: Prisma.TransactionClient,
|
||||
grantId: string | null,
|
||||
): Promise<(domain: ShareDomain) => boolean> {
|
||||
if (grantId === null) return () => true;
|
||||
const grant = await tx.accountGrant.findUnique({
|
||||
where: { id: grantId },
|
||||
select: { scopeJson: true },
|
||||
});
|
||||
if (!grant) return () => false;
|
||||
return (domain) => grantCoversDomain(grant, domain);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listTargets, replaceTargets } from "@/lib/links";
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
@@ -37,29 +36,6 @@ export const SERIES_SELECT = {
|
||||
seriesDoses: true,
|
||||
} as const satisfies Prisma.VaccinationRecordSelect;
|
||||
|
||||
/**
|
||||
* Which sections of the record the caller is actually inside.
|
||||
*
|
||||
* Returns a predicate rather than a set so the owner path costs nothing: with
|
||||
* no grant there is nothing to look up and every domain is open. A grant that
|
||||
* vanished between the auth check and here answers "no" to everything, which
|
||||
* is the fail-closed direction. Same shape as the visits service, deliberately
|
||||
* — the two features sit on the same seam and a second spelling of this would
|
||||
* be a second thing to keep in step.
|
||||
*/
|
||||
export async function actingDomainVisibility(
|
||||
tx: Prisma.TransactionClient,
|
||||
grantId: string | null,
|
||||
): Promise<(domain: ShareDomain) => boolean> {
|
||||
if (grantId === null) return () => true;
|
||||
const grant = await tx.accountGrant.findUnique({
|
||||
where: { id: grantId },
|
||||
select: { scopeJson: true },
|
||||
});
|
||||
if (!grant) return () => false;
|
||||
return (domain) => grantCoversDomain(grant, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole live history for one account, in the shape the derivation wants.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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: unknown[];
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user