test(healthkit): cover device-event ingest + the awareness surface

Add mapping tests for the five event classes (verdict resolution,
severity grading, the value-pinned-to-1 contract, unknown-codepoint
fallback, and that a continuous measurement never gets a classification),
the awareness-card gating + device-verdict framing + disclaimer presence,
and the events route narrowing + empty-state contract. Extend the enum,
chart-token and Coach-gate drift guards for the new event types.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 22:10:16 +02:00
1 parent 135141a750
commit 95064b405a
5 files changed
+280 -11

No files matched your search

@@ -53,6 +53,11 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
// `coach`: a user with assessments enabled but Coach disabled can warm.
"src/app/api/insights/pregenerate/route.ts",
"src/app/api/insights/pulse-status/route.ts",
// v1.10.0 — device-flagged event awareness timeline (categorical
// events, WX-B). Pure DB read of the device's own verdicts; gates on
// the same `insightStatus` sub-flag as the assessment routes (no Coach
// prose).
"src/app/api/insights/rhythm-events/route.ts",
"src/app/api/insights/weight-status/route.ts",
];
@@ -0,0 +1,115 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.10.0 — categorical events (WX-B). `<RhythmEventsCard>` unit tests.
*
* The card is the device-flagged event awareness surface. The load-bearing
* behaviour under test:
* - data-availability gating: the card un-mounts entirely (renders
* nothing) when the user has no events or while the payload is in
* flight — never an empty / alarming card.
* - the device-verdict framing ("Your device flagged …") and the
* permanent regulatory disclaimer that states HealthLog does not make a
* medical assessment.
*
* The component depends on `useAuth` (gate) + TanStack Query (`useQuery`)
* for the payload, so both are mocked and the assertions run through SSR.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })),
}));
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
const { RhythmEventsCard } = await import("../rhythm-events-card");
interface RhythmEvent {
id: string;
type: string;
classification: string | null;
occurredAt: string;
source: string;
deviceType: string | null;
}
function renderWith(
data: { events: RhythmEvent[]; hasEvents: boolean } | undefined,
) {
useQueryMock.mockReturnValue({ data, isLoading: false });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RhythmEventsCard />
</I18nProvider>,
);
}
const IRREGULAR_EVENT: RhythmEvent = {
id: "evt_1",
type: "IRREGULAR_RHYTHM_NOTIFICATION",
classification: "IRREGULAR",
occurredAt: "2026-06-01T09:15:00.000Z",
source: "APPLE_HEALTH",
deviceType: "watch",
};
const STEADINESS_EVENT: RhythmEvent = {
id: "evt_2",
type: "WALKING_STEADINESS_EVENT",
classification: "VERY_LOW",
occurredAt: "2026-05-20T17:00:00.000Z",
source: "APPLE_HEALTH",
deviceType: "phone",
};
describe("<RhythmEventsCard>", () => {
it("renders nothing before the payload resolves", () => {
useQueryMock.mockReturnValue({ data: undefined, isLoading: true });
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RhythmEventsCard />
</I18nProvider>,
);
expect(html).toBe("");
});
it("renders nothing when the user has no events (data-availability gate)", () => {
const html = renderWith({ events: [], hasEvents: false });
expect(html).toBe("");
});
it("renders the timeline + each event row when events exist", () => {
const html = renderWith({
events: [IRREGULAR_EVENT, STEADINESS_EVENT],
hasEvents: true,
});
expect(html).toContain('data-slot="rhythm-events-card"');
expect(html).toContain('data-slot="rhythm-events-timeline"');
const rows = (html.match(/data-slot="rhythm-event-row"/g) ?? []).length;
expect(rows).toBe(2);
expect(html).toContain('data-event-type="IRREGULAR_RHYTHM_NOTIFICATION"');
expect(html).toContain('data-event-type="WALKING_STEADINESS_EVENT"');
});
it("frames the verdict as the DEVICE's decision, never HealthLog's", () => {
const html = renderWith({ events: [IRREGULAR_EVENT], hasEvents: true });
expect(html).toContain('data-slot="rhythm-event-verdict"');
// Verbatim device-decision framing — load-bearing regulatory copy.
expect(html).toContain("Your device flagged a possible irregular rhythm.");
});
it("renders the permanent regulatory disclaimer", () => {
const html = renderWith({ events: [IRREGULAR_EVENT], hasEvents: true });
expect(html).toContain('data-slot="rhythm-events-disclaimer"');
// The disclaimer must state HealthLog does not make a medical
// assessment and does not diagnose.
expect(html).toContain("not a medical assessment by HealthLog");
expect(html).toContain("does not diagnose");
});
});
@@ -67,10 +67,16 @@ const EXPECTED_TYPES = [
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
"BREATHING_DISTURBANCES",
// ── v1.10.0 — categorical events (WX-B) ──
"IRREGULAR_RHYTHM_NOTIFICATION",
"HIGH_HEART_RATE_EVENT",
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 45 canonical measurement types", () => {
it("exposes the 50 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -151,6 +157,17 @@ describe("measurementTypeEnum coverage", () => {
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
"BREATHING_DISTURBANCES",
// v1.10.0 — categorical events (WX-B). Device-flagged EVENT rows are
// discrete on-device notifications, not vital-sign readings. They are
// surfaced on the Insights awareness timeline with their own regulatory
// disclaimer, never in the clinical vitals PDF (their value is always 1
// and they carry the device's verdict, not a measured quantity). See
// doctor-report-pdf-core.ts for the matching exclusion rationale.
"IRREGULAR_RHYTHM_NOTIFICATION",
"HIGH_HEART_RATE_EVENT",
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { MeasurementType } from "@/generated/prisma/client";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
import {
ALLOWED_CHART_TOKENS,
parseChartTokens,
@@ -214,12 +215,20 @@ describe("ALLOWED_CHART_TOKENS — drift guard", () => {
);
});
it("covers every MeasurementType enum value", () => {
it("covers every chartable MeasurementType enum value", () => {
// If a future schema migration adds a new MeasurementType, this test
// fails until the new `metric:<TYPE>` is also added to the allowlist.
// (`NOTE` is excluded if it ever appears — currently the schema has none.)
//
// v1.10.0 — the categorical EVENT classes (irregular-rhythm /
// high-low-HR / walking-steadiness / breathing-disturbance) are
// deliberately NOT chartable: they are discrete device-flagged
// occurrences with no continuous series, surfaced on the awareness
// timeline rather than the inline chart renderer. They carry no
// `metric:<TYPE>` token by design.
const enumTokens = (Object.values(MeasurementType) as string[])
.filter((value) => value !== "NOTE")
.filter((value) => !EVENT_MEASUREMENT_TYPES.has(value))
.map((value) => `metric:${value}`);
for (const token of enumTokens) {
@@ -66,6 +66,15 @@ describe("APPLE_HEALTH_TYPE_MAP", () => {
// HRV / VO2 max / sleep analysis.
"HKQuantityTypeIdentifierAppleSleepingWristTemperature",
"HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances",
// v1.10.0 — the categorical EVENT classes carry health-screening
// verdicts (rhythm / heart-rate / mobility / breathing) the user
// explicitly enabled on-device; flagged privacy-sensitive so the
// server audit trail can distinguish them.
"HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
"HKCategoryTypeIdentifierHighHeartRateEvent",
"HKCategoryTypeIdentifierLowHeartRateEvent",
"HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
"HKCategoryTypeIdentifierSleepApneaEvent",
].sort(),
);
// Audio exposure + time-in-daylight are environment / lifestyle
@@ -138,11 +147,10 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => {
// Cardiovascular / clinical
"HKQuantityTypeIdentifierAtrialFibrillationBurden",
"HKQuantityTypeIdentifierPeripheralPerfusionIndex",
// Mobility — `AppleWalkingSteadiness` moved into the mapped
// set in v1.4.30; `NumberOfTimesFallen` moved into the mapped set
// in v1.10.0 (FALL_COUNT). The walking-steadiness EVENT stays
// deferred (a category-type event, not a quantity).
"HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
// Mobility — `AppleWalkingSteadiness` moved into the mapped set in
// v1.4.30. In v1.10.0 both remaining identifiers moved in too:
// `NumberOfTimesFallen` → FALL_COUNT and `AppleWalkingSteadinessEvent`
// → WALKING_STEADINESS_EVENT, so neither stays deferred.
// Respiratory / pulmonary
"HKQuantityTypeIdentifierForcedExpiratoryVolume1",
"HKQuantityTypeIdentifierForcedVitalCapacity",
@@ -154,10 +162,10 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => {
"HKQuantityTypeIdentifierElectrodermalActivity",
"HKQuantityTypeIdentifierBloodAlcoholContent",
"HKQuantityTypeIdentifierNikeFuel",
// Heart-rhythm event flags
"HKCategoryTypeIdentifierLowHeartRateEvent",
"HKCategoryTypeIdentifierHighHeartRateEvent",
"HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
// Heart-rhythm event flags — `LowHeartRateEvent`,
// `HighHeartRateEvent` + `IrregularHeartRhythmEvent` moved into the
// mapped set in v1.10.0 as categorical events. `LowCardioFitnessEvent`
// stays deferred (no awareness surface yet).
"HKCategoryTypeIdentifierLowCardioFitnessEvent",
// Audio-exposure events — environmental + headphone moved into
// the mapped set in v1.4.30 (AUDIO_EXPOSURE_EVENT). The general
@@ -757,3 +765,118 @@ describe("v1.10.0 additive HealthKit signals (WX-A)", () => {
});
});
});
describe("v1.10.0 categorical events (WX-B)", () => {
const ts = "2026-06-02T07:30:00.000Z";
it("maps every event identifier to its EVENT MeasurementType", () => {
const expected: Array<[string, string]> = [
[
"HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
"IRREGULAR_RHYTHM_NOTIFICATION",
],
["HKCategoryTypeIdentifierHighHeartRateEvent", "HIGH_HEART_RATE_EVENT"],
["HKCategoryTypeIdentifierLowHeartRateEvent", "LOW_HEART_RATE_EVENT"],
[
"HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
"WALKING_STEADINESS_EVENT",
],
[
"HKCategoryTypeIdentifierSleepApneaEvent",
"BREATHING_DISTURBANCE_EVENT",
],
];
for (const [hkId, type] of expected) {
const mapping = APPLE_HEALTH_TYPE_MAP[hkId];
expect(mapping, `${hkId} should be mapped`).toBeDefined();
expect(mapping.measurementType).toBe(type);
// EVENT rows are always a single fired occurrence regardless of the
// inbound number — the device fired it once.
expect(mapping.convertToDbUnit(999)).toBe(1);
expect(mapping.dbUnit).toBe("event");
}
});
it("resolves the irregular-rhythm event to the device's IRREGULAR verdict", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
value: 0,
unit: "event",
startDate: ts,
endDate: ts,
categoryValue: 0,
});
expect(out).toEqual({
type: "IRREGULAR_RHYTHM_NOTIFICATION",
value: 1,
unit: "event",
takenAt: new Date(ts),
rhythmClassification: "IRREGULAR",
});
});
it("grades the walking-steadiness event severity from the codepoint", () => {
const low = mapAppleHealthEntry({
hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
value: 0,
unit: "event",
startDate: ts,
endDate: ts,
categoryValue: 1, // initialLow
});
expect(low!.rhythmClassification).toBe("LOW");
const veryLow = mapAppleHealthEntry({
hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
value: 0,
unit: "event",
startDate: ts,
endDate: ts,
categoryValue: 3, // initialVeryLow
});
expect(veryLow!.rhythmClassification).toBe("VERY_LOW");
});
it("falls back to FIRED for the neutral high/low-HR + breathing events", () => {
for (const hkId of [
"HKCategoryTypeIdentifierHighHeartRateEvent",
"HKCategoryTypeIdentifierLowHeartRateEvent",
"HKCategoryTypeIdentifierSleepApneaEvent",
]) {
const out = mapAppleHealthEntry({
hkIdentifier: hkId,
value: 0,
unit: "event",
startDate: ts,
endDate: ts,
});
expect(out!.value).toBe(1);
expect(out!.rhythmClassification).toBe("FIRED");
}
});
it("degrades an unknown steadiness codepoint to the fallback rather than dropping it", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
value: 0,
unit: "event",
startDate: ts,
endDate: ts,
categoryValue: 99, // Apple introduced a codepoint we don't enumerate
});
expect(out).not.toBeNull();
expect(out!.rhythmClassification).toBe("LOW");
});
it("never sets rhythmClassification on a continuous measurement", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 80,
unit: "kg",
startDate: ts,
endDate: ts,
categoryValue: 1,
});
expect(out!.rhythmClassification).toBeUndefined();
});
});