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
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 17:09:03 +02:00
1 parent f74efc44aa
commit 349dac3a07
6 files changed
+170 -15

No files matched your search

+2 -4
View File
@@ -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,
+6 -7
View File
@@ -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);
}
+2 -4
View File
@@ -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
@@ -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();
});
});
@@ -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);
}
});
});
+38
View File
@@ -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();
});
});