Files
HealthLog/src/lib/insights/__tests__/features.test.ts
T
Marc-André Bombeck 1114cf7e63 chore(release): v1.4.40 — comprehensive Critical+High architecture closure plus iOS PB30 enablement
Eleven touch-disjoint waves landed on develop plus a six-axis QA
reconcile round before tagging. See CHANGELOG.md for the user-facing
prose; this commit message documents the wave + report inventory for
release-management traceability.

Waves shipped (each with a phase report under .planning/):

- phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the
  analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the
  v1.4.39 empirical-trace finding #1 (Prisma pool starvation).
- phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3
  integration contracts close audit Critical Finding #3 (soft-delete
  invisibility) across dashboard, analytics, summaries, rollup tiers,
  and the Coach snapshot path.
- phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three
  /api/insights/{features,targets,cards} routes plus the
  compliance-rollup hook gap closure for bulk-projection mints; the
  post-QA reconcile patch closes the six remaining measurement.findMany
  sites in the cluster that still leaked tombstoned rows.
- phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation
  under src/lib/rollups/* with zero orphan re-exports; lint, typecheck,
  knip, and 93/93 rollup tests green post-restructure.
- phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the
  queryKey factory-bypass walker test (extended to comparison/ in the
  reconcile pass).
- phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17
  paired-section tests pinning the 9 SB-3 disclosure requirements.
- phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association
  endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity
  assertions guarding against split rotation.
- phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER
  time-sensitive APNs payload (priority 10 + interruption-level)
  scoped via a parameterised test that pins all 6 other event-types
  do not bypass Focus. AP-2 .p8 install gates real delivery.
- phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with
  append-only invariant pinned in test; revoke-then-regrant leaves
  both rows; idempotent DELETE returns 200 + receipt: null.
- phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers
  (week/month/year) wired through summaries-slice and
  health-score-fast-path with a linear-composability parity test
  pinning the cross-consumer routing contract.
- phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed
  across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation
  eliminates a sibling-drift risk; knip whitelist trimmed to three
  files pending follow-up.

QA reconcile patches applied per the v1.4.40 QA round (six reports
under .planning/round-v1440-QA-*-findings.md):

- senior C1 — dashboard-suspense-boundaries regex aligned with the
  post-W-INFRA Thread 2 userTimezone-local lift.
- senior H1 — soft-delete filter added to the six remaining
  insights measurement.findMany sites.
- specialist H1 — Promise.allSettled around bulk-projection
  compliance recompute on both /api/medications/intake and
  /api/dashboard/summary.
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets
  factory; walker test extended to src/components/comparison/.
- security M1 — consent artefact 64 KB cap enforced via
  Buffer.byteLength (UTF-8 bytes, not UTF-16 code units).

Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip
--include files,dependencies,binaries,unlisted, pnpm test --run
(4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over
the v1.4.39 -> v1.4.40 span.

User-facing prose: see CHANGELOG.md.
2026-05-21 12:48:02 +02:00

190 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
$queryRawUnsafe: vi.fn(),
},
}));
// Stub the mood-rollup warm-up so test runs don't fire the real
// recompute aggregate. The warm-up is fire-and-forget on the route.
vi.mock("@/lib/rollups/mood-rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
"@/lib/rollups/mood-rollups",
);
return {
...actual,
ensureUserMoodRollupsFresh: vi
.fn()
.mockResolvedValue({ recomputed: false }),
};
});
vi.mock("@/lib/medication-category", () => ({
getMedicationCategories: vi.fn(async () => ({})),
}));
import { prisma } from "@/lib/db";
import {
extractFeatures,
FeaturesPayloadTooLargeError,
FEATURES_MAX_BYTES,
type RawFeatures,
} from "../features";
const prismaMock = prisma as unknown as {
user: { findUnique: ReturnType<typeof vi.fn> };
measurement: { findMany: ReturnType<typeof vi.fn> };
measurementRollup: { findMany: ReturnType<typeof vi.fn> };
moodEntry: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
moodEntryRollup: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
medication: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
};
const dayMs = 24 * 60 * 60 * 1000;
function rollupRow(daysAgo: number, mean: number, count: number) {
return {
bucketStart: new Date(Date.now() - daysAgo * dayMs),
count,
mean,
minValue: mean,
maxValue: mean,
sd: 0,
slope: 0,
r2: 0,
computedAt: new Date(),
};
}
beforeEach(() => {
vi.resetAllMocks();
prismaMock.user.findUnique.mockResolvedValue({
heightCm: 180,
dateOfBirth: new Date("1980-01-01"),
gender: "MALE",
});
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findFirst.mockResolvedValue(null);
prismaMock.moodEntryRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntryRollup.findFirst.mockResolvedValue(null);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
});
describe("extractFeatures — v1.4.36 W3 bucketed payload", () => {
it("does not attach bucketedMeasurements when includeRaw=false", async () => {
const features = await extractFeatures("user-1", false);
const asRecord = features as unknown as Record<string, unknown>;
expect(asRecord.bucketedMeasurements).toBeUndefined();
expect(asRecord.rawMeasurements).toBeUndefined();
});
it("attaches DAY / WEEK / MONTH buckets from measurement_rollups when includeRaw=true", async () => {
// Two WEIGHT DAY buckets in the 0-90d window and one MONTH bucket
// in the 365-1825d window. The reader is called once per
// (type, granularity) combination — return data only for the two
// we care about, empty for the rest.
prismaMock.measurementRollup.findMany.mockImplementation(
async (args: { where: { type: string; granularity: string } }) => {
if (args.where.type === "WEIGHT" && args.where.granularity === "DAY") {
return [rollupRow(10, 82.5, 2), rollupRow(20, 82.7, 1)];
}
if (
args.where.type === "WEIGHT" &&
args.where.granularity === "MONTH"
) {
return [rollupRow(400, 85.1, 28)];
}
return [];
},
);
const features = (await extractFeatures("user-1", true)) as RawFeatures;
expect(features.bucketedMeasurements).toBeDefined();
const weightDay = features.bucketedMeasurements.find(
(s) => s.type === "WEIGHT" && s.granularity === "DAY",
);
expect(weightDay).toBeDefined();
expect(weightDay?.buckets).toHaveLength(2);
expect(weightDay?.buckets[0]).toMatchObject({
mean: 82.5,
count: 2,
});
expect(weightDay?.buckets[0].bucketStart).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/,
);
const weightMonth = features.bucketedMeasurements.find(
(s) => s.type === "WEIGHT" && s.granularity === "MONTH",
);
expect(weightMonth?.buckets).toHaveLength(1);
expect(weightMonth?.buckets[0].count).toBe(28);
});
it("drops empty (type, granularity) series so the payload never carries labelled-but-empty arrays", async () => {
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
const features = (await extractFeatures("user-1", true)) as RawFeatures;
expect(features.bucketedMeasurements).toEqual([]);
});
it("keeps anthropometrics (heightCm / ageYears / gender) on context", async () => {
const features = await extractFeatures("user-1", false);
expect(features.context.heightCm).toBe(180);
expect(features.context.gender).toBe("MALE");
expect(features.context.ageYears).toBeGreaterThan(40);
});
// Constructs a ~5.6 MB rollup payload and runs the whole serialiser
// pipeline; the slower GitHub-Actions runners cross the default 5 s
// budget. Raise this single test only — the rest of the suite stays
// on the default timeout. Vitest 4 takes the timeout as a numeric
// second argument (the old `it(name, fn, options)` signature was
// removed in v4).
it(
"throws FeaturesPayloadTooLargeError when the serialised payload exceeds the 5 MB cap",
{ timeout: 30_000 },
async () => {
// Fabricate an absurdly long bucket list so the JSON dump crosses
// the ceiling. ~200 KB per series × 28 series ≈ ~5.6 MB.
const giant = new Array(200_000).fill(null).map((_, i) => ({
bucketStart: new Date(Date.now() - i * dayMs),
count: i,
mean: i,
minValue: i,
maxValue: i,
sd: 0,
slope: 0,
r2: 0,
computedAt: new Date(),
}));
prismaMock.measurementRollup.findMany.mockResolvedValue(giant);
await expect(extractFeatures("user-1", true)).rejects.toThrow(
FeaturesPayloadTooLargeError,
);
},
);
it("exposes the size cap as a public constant", () => {
expect(FEATURES_MAX_BYTES).toBe(5 * 1024 * 1024);
});
});