From 349dac3a07a76c5bd48151a7d5d5b12aa4e80c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 06:06:23 +0200 Subject: [PATCH] test(profile): cover the third gender value where it used to be lost Adds the cases that were missing everywhere except the FHIR conformance suite: an empty string clears without taking its siblings down, OTHER round-trips through the profile and onboarding writes, an unsupported value is still refused and says which field it is about, the Strain model scores an unanswered account on neither side, the dashboard bands give OTHER the neutral fallback, the snapshot payload carries the value instead of flattening it, and the doctor-report cover omits the row rather than claiming a value the account never chose. Refs #645 --- src/app/api/insights/generate/route.ts | 6 +- src/components/onboarding/baseline-form.tsx | 13 ++- src/lib/ai/coach/snapshot.ts | 6 +- src/lib/dashboard/__tests__/snapshot.test.ts | 29 ++++++ .../__tests__/header-profile-gender.test.ts | 93 +++++++++++++++++++ src/lib/profile/__tests__/sex.test.ts | 38 ++++++++ 6 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts create mode 100644 src/lib/profile/__tests__/sex.test.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index c17109db5..271389d2b 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -68,6 +68,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; +import { toProfileSex } from "@/lib/profile/sex"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { normalizeLocale } from "@/lib/insights/status-shared"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; @@ -597,10 +598,7 @@ export const POST = apiHandler((request: NextRequest) => // v1.10.0 — fold a notable derived wellness signal (readiness / recovery // shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block. // Reads the one derived contract; null (the common case) costs nothing. - const derivedSex = - dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE" - ? (dbUser.gender as "MALE" | "FEMALE") - : null; + const derivedSex = toProfileSex(dbUser?.gender); const derivedBriefing = await detectDerivedBriefingSignals(userId, { ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null), sex: derivedSex, diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index b1ba7f94d..f905c1ca1 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -29,6 +29,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { queryKeys } from "@/lib/query-keys"; +import { toProfileSex } from "@/lib/profile/sex"; import { AnamnesisCard, buildAnamnesisAboutMeBody, @@ -139,13 +140,11 @@ export function BaselineForm() { const heightCm = heightAdapter.toCanonicalCm(form.height); if (heightCm !== null) profileBody.heightCm = heightCm; if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth; - if ( - form.gender === "MALE" || - form.gender === "FEMALE" || - form.gender === "OTHER" - ) { - profileBody.gender = form.gender; - } + // Every value the profile stores travels; the guard drops only the + // "prefer not to say" placeholder. It used to list two of the three + // by hand, so a third-value answer vanished on submit without a word. + const gender = toProfileSex(form.gender); + if (gender) profileBody.gender = gender; if (Object.keys(profileBody).length > 0) { await apiPut("/api/auth/profile", profileBody); } diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index ae160c7f2..f6cbe5fcc 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -53,6 +53,7 @@ import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope"; import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import type { BaselineProfile } from "@/lib/insights/derived"; +import { toProfileSex } from "@/lib/profile/sex"; import { CLUSTER_PRIORITY, clusterSourcesFromPrefs, @@ -691,10 +692,7 @@ async function buildCoachSnapshotImpl( const derivedCtx = features.context; const derivedProfile: BaselineProfile = { ageYears: derivedCtx?.ageYears ?? null, - sex: - derivedCtx?.gender === "MALE" || derivedCtx?.gender === "FEMALE" - ? (derivedCtx.gender as "MALE" | "FEMALE") - : null, + sex: toProfileSex(derivedCtx?.gender), heightCm: derivedCtx?.heightCm ?? null, }; // v1.18.0 — the derived block + WHOOP-native dayStrain + trajectory are diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 19ef63e60..288e2c39f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -1276,3 +1276,32 @@ describe("buildDashboardSnapshot — scoreRings wire", () => { expect(snap.tiles.summaries.WEIGHT).toBeDefined(); }); }); + +describe("buildDashboardSnapshot — the wire carries the stored gender", () => { + beforeEach(() => { + // Prime only what the user block needs; this suite must not inherit + // another test's mock state. + probeRollupCoverage.mockResolvedValue(new Map()); + isFullyCovered.mockReturnValue(false); + computeBpInTargetFastPath.mockResolvedValue(null); + }); + + it("emits OTHER rather than flattening it to no answer", async () => { + // Narrowing to two values on the wire made an account that answered + // OTHER indistinguishable from one that answered nothing, on every + // client reading this payload. + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ gender: "OTHER" }), + ); + expect(snap.user.gender).toBe("OTHER"); + }); + + it("still reports no answer as null", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ gender: null }), + ); + expect(snap.user.gender).toBeNull(); + }); +}); diff --git a/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts b/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts new file mode 100644 index 000000000..ce126d2b5 --- /dev/null +++ b/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; +import { buildHeaderProfileSection } from "../header-profile-section"; +import type { + DoctorReportPdfRenderContext, + DoctorReportPdfCursorState, +} from "../render-context"; + +/** + * A line on a clinical cover sheet is a positive claim about the patient. + * The gender row used to label ANY unrecognised stored string as the third + * value, so a legacy or malformed value asserted something the account + * never chose. Absence has to read as absence. + */ +function renderPatientLines(gender: string | null): string[] { + const lines: string[] = []; + const doc = { + setFontSize: vi.fn(), + setFont: vi.fn(), + setTextColor: vi.fn(), + setDrawColor: vi.fn(), + setLineWidth: vi.fn(), + line: vi.fn(), + addPage: vi.fn(), + getNumberOfPages: () => 1, + splitTextToSize: (s: string) => [s], + text: (line: string) => { + lines.push(line); + }, + internal: { pageSize: { getWidth: () => 210, getHeight: () => 297 } }, + }; + const context = { + doc, + data: { + patient: { + username: "testuser", + fullName: null, + dateOfBirth: null, + gender, + heightCm: null, + insurerName: null, + insurerIkNumber: null, + }, + period: { + since: "2026-06-01T00:00:00.000Z", + start: null, + end: null, + days: 30, + }, + practiceName: null, + measurements: {}, + stats: {}, + compliance: {}, + }, + // The translator echoes the key so an assertion can name the label it + // expects instead of a locale string. + t: (key: string) => key, + num: (value: number) => String(value), + fmtDate: (iso: string) => iso.slice(0, 10), + now: new Date("2026-07-26T00:00:00.000Z"), + insuranceNumber: null, + margin: 15, + pageWidth: 210, + contentMaxY: 280, + ensureSpace: (current: number) => current, + } as unknown as DoctorReportPdfRenderContext; + buildHeaderProfileSection(context, { + y: 20, + } as unknown as DoctorReportPdfCursorState); + return lines; +} + +describe("doctor-report cover — gender row", () => { + it("labels each of the three stored values", () => { + expect(renderPatientLines("MALE")).toContain( + "doctorReport.gender: doctorReport.genderMale", + ); + expect(renderPatientLines("FEMALE")).toContain( + "doctorReport.gender: doctorReport.genderFemale", + ); + expect(renderPatientLines("OTHER")).toContain( + "doctorReport.gender: doctorReport.genderOther", + ); + }); + + it("omits the row entirely for no answer or an unrecognised value", () => { + for (const stored of [null, "", "diverse", "male"]) { + const lines = renderPatientLines(stored); + expect( + lines.some((line) => line.startsWith("doctorReport.gender:")), + ).toBe(false); + } + }); +}); diff --git a/src/lib/profile/__tests__/sex.test.ts b/src/lib/profile/__tests__/sex.test.ts new file mode 100644 index 000000000..bd0d8dad9 --- /dev/null +++ b/src/lib/profile/__tests__/sex.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { toProfileSex, binaryReferenceSex } from "@/lib/profile/sex"; + +/** + * The column holds three values. Two separate defects shipped from code + * that could only hold two: the FHIR export mapped the third to `female`, + * and the Strain model scored it on the male coefficients. These two + * functions are the only sanctioned way to move between the widths. + */ +describe("toProfileSex", () => { + it("carries every stored value through", () => { + expect(toProfileSex("MALE")).toBe("MALE"); + expect(toProfileSex("FEMALE")).toBe("FEMALE"); + expect(toProfileSex("OTHER")).toBe("OTHER"); + }); + + it("reports no answer as no answer, and never guesses one", () => { + expect(toProfileSex(null)).toBeNull(); + expect(toProfileSex(undefined)).toBeNull(); + expect(toProfileSex("")).toBeNull(); + expect(toProfileSex("male")).toBeNull(); + expect(toProfileSex("diverse")).toBeNull(); + }); +}); + +describe("binaryReferenceSex", () => { + it("keeps the two values a sex-split reference table has rows for", () => { + expect(binaryReferenceSex("MALE")).toBe("MALE"); + expect(binaryReferenceSex("FEMALE")).toBe("FEMALE"); + }); + + it("resolves the third value to null rather than to one of the two", () => { + expect(binaryReferenceSex("OTHER")).toBeNull(); + expect(binaryReferenceSex(null)).toBeNull(); + expect(binaryReferenceSex(undefined)).toBeNull(); + expect(binaryReferenceSex("")).toBeNull(); + }); +});