diff --git a/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts b/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts new file mode 100644 index 000000000..2fc4e3a07 --- /dev/null +++ b/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts @@ -0,0 +1,197 @@ +/** + * Structural guard — a dashboard tile may not be gated on a closed enum + * whose column is nullable. + * + * The Blood Glucose tile decided whether to paint by filtering the four + * `GlucoseContext` members against a per-context summary record and asking + * whether any of them held readings. Every named context is a legal value of + * the column, so the list read as exhaustive — but `glucose_context` is + * nullable, and a meter synced through Apple Health writes no meal-time + * metadata at all. An account with one hundred percent untagged readings + * therefore matched no bucket, and the tile was omitted with the module on, + * the layout toggle on, and a reading from today. Nothing warned: every + * behavioural test in the suite seeded a tagged reading, so the missing arm + * was invisible from the outside (#943). + * + * What the guard asserts: wherever a dashboard tile surface derives presence + * from a list of enum buckets, the list must carry a null / unknown arm — or + * the call site must write down why it cannot. It matches on the list + * EXPRESSION, following one hop through a named constant, because the fix for + * this class is always the same: the untagged bucket joins the list the + * eligibility loop already walks. + * + * Its limits, written down so a later reader does not over-trust it: it sees + * presence tests spelled with `count`, and a bucket list that is either an + * inline literal or a single named constant. An eligibility gate that counted + * rows some other way, or assembled its bucket list at runtime, would slip + * past. The `detect()` self-controls below exist so a refactor that silently + * stops matching anything fails here rather than going quiet. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +/** + * A bucket list that carries one of these tokens has an arm for rows the + * enum cannot name. Matched against the list expression's source text, so a + * shared constant (`GLUCOSE_CONTEXT_BUCKETS = [...NAMED, UNSPECIFIED]`) + * satisfies it through its own name. + */ +const UNTAGGED_ARM = /UNSPECIFIED|UNKNOWN|UNTAGGED|OTHER|NONE/; + +/** The written-reason escape hatch, with a non-empty reason after it. */ +const WRITTEN_REASON = /tile-eligibility-exempt:\s*\S/; + +/** Dashboard surfaces that decide whether a tile paints. */ +const SURFACES = [ + "src/app/page-client.tsx", + ...listSourceFiles("src/components/dashboard"), + ...listSourceFiles("src/lib/dashboard"), +]; + +function listSourceFiles(dir: string): string[] { + const abs = join(process.cwd(), dir); + return readdirSync(abs) + .filter((name) => /\.tsx?$/.test(name)) + .map((name) => join(dir, name)) + .filter((rel) => statSync(join(process.cwd(), rel)).isFile()); +} + +interface Finding { + /** The bucket-list expression as written at the eligibility site. */ + list: string; + /** 1-based line of the eligibility site. */ + line: number; +} + +/** + * Iteration over a bucket list: `for (const x of LIST)` or + * `LIST.filter(…)` / `.some(…)`. The list is either an inline array literal + * or an identifier. + */ +const ITERATION = + /(?:for\s*\(\s*(?:const|let)\s+[^)]*?\bof\s+(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*\))|(?:(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*\.\s*(?:filter|some|flatMap)\s*\()/g; + +/** A presence test — "does this bucket hold any readings". */ +const PRESENCE = /\bcount\b/; + +/** How far past the iteration head a presence test still belongs to it. */ +const BODY_WINDOW = 500; + +/** + * Every eligibility site in `source` whose bucket list has no untagged arm + * and no written reason. `resolve` supplies the initializer text of a named + * constant so the guard can follow one hop out of the file. + */ +export function detect( + source: string, + resolve: (name: string) => string | null, +): Finding[] { + const findings: Finding[] = []; + ITERATION.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = ITERATION.exec(source)) !== null) { + const list = match[1] ?? match[2]; + if (!list) continue; + const body = source.slice(match.index, match.index + BODY_WINDOW); + if (!PRESENCE.test(body)) continue; + // Only enum-shaped lists are in scope: an inline literal of UPPER_SNAKE + // members, or an UPPER_SNAKE constant name. + const inline = list.startsWith("["); + const members = inline ? list.match(/"[A-Z][A-Z0-9_]*"/g) : null; + if (inline && (members?.length ?? 0) < 2) continue; + if (!inline && !/^[A-Z][A-Z0-9_]*$/.test(list)) continue; + const expression = inline ? list : (resolve(list) ?? list); + if (UNTAGGED_ARM.test(expression)) continue; + const before = source.slice(Math.max(0, match.index - 400), match.index); + if (WRITTEN_REASON.test(before)) continue; + findings.push({ + list, + line: source.slice(0, match.index).split("\n").length, + }); + } + return findings; +} + +/** Initializer text of `export const NAME = …` / `const NAME = …` in `src/`. */ +function resolveConstant(name: string): string | null { + for (const file of CONSTANT_SOURCES) { + const source = readFileSync(join(process.cwd(), file), "utf8"); + const declaration = new RegExp( + `(?:export\\s+)?const\\s+${name}\\s*(?::[^=]+)?=\\s*(\\[[\\s\\S]*?\\])`, + ).exec(source); + if (declaration) return declaration[1]; + } + return null; +} + +/** Files a dashboard surface may import a bucket list from. */ +const CONSTANT_SOURCES = ["src/lib/glucose.ts", ...SURFACES]; + +describe("detect() — the matcher itself", () => { + it("flags the shape that shipped: four enum members, no null arm", () => { + const shipped = ` + const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"]; + const present = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"].filter( + (ctx) => (byContext[ctx]?.count ?? 0) > 0, + ); + `; + const findings = detect(shipped, () => null); + expect(findings).toHaveLength(1); + expect(findings[0].list).toContain("FASTING"); + }); + + it("accepts a list that carries an untagged arm", () => { + const fixed = ` + for (const bucket of GLUCOSE_CONTEXT_BUCKETS) { + const summary = byContext?.[bucket]; + if (!summary || summary.count <= 0) continue; + } + `; + const resolve = () => '["FASTING", "BEDTIME", GLUCOSE_CONTEXT_UNSPECIFIED]'; + expect(detect(fixed, resolve)).toEqual([]); + // …and rejects the same site when the constant has no arm, so the pass + // above is the arm's doing and not the resolver's. + expect(detect(fixed, () => '["FASTING", "BEDTIME"]')).toHaveLength(1); + }); + + it("accepts a written reason", () => { + const exempt = ` + // tile-eligibility-exempt: sleep stages are never null on this path. + for (const stage of ["DEEP", "REM"]) { + if (byStage[stage].count > 0) return true; + } + `; + expect(detect(exempt, () => null)).toEqual([]); + }); + + it("ignores lists that are not presence tests", () => { + const unrelated = ` + const labels = ["WEIGHT", "PULSE"].map((type) => LABELS[type]); + `; + expect(detect(unrelated, () => null)).toEqual([]); + }); +}); + +describe("dashboard tile eligibility", () => { + it("scans the surfaces it claims to scan", () => { + // An empty match set must fail rather than read as clean: the guard is + // worthless the day its matchers stop finding the real call sites. + expect(SURFACES.length).toBeGreaterThan(5); + const sited = SURFACES.filter((file) => + PRESENCE.test(readFileSync(join(process.cwd(), file), "utf8")), + ); + expect(sited.length).toBeGreaterThan(0); + }); + + it("computes no tile's eligibility from an enum without a null arm", () => { + const offenders: string[] = []; + for (const file of SURFACES) { + const source = readFileSync(join(process.cwd(), file), "utf8"); + for (const finding of detect(source, resolveConstant)) { + offenders.push(`${file}:${finding.line} — ${finding.list}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/tests/integration/dashboard-glucose-untagged.test.ts b/tests/integration/dashboard-glucose-untagged.test.ts new file mode 100644 index 000000000..810696cac --- /dev/null +++ b/tests/integration/dashboard-glucose-untagged.test.ts @@ -0,0 +1,165 @@ +/** + * #943 — untagged glucose readings light the dashboard tile. + * + * A reporter's meter syncs through Apple Health and never writes HealthKit's + * blood-glucose meal-time metadata, so all of their readings carry a NULL + * `glucose_context`. The tile strip filtered the four named contexts against + * the per-context summaries, matched none, and omitted the tile — module on, + * layout toggle on, a reading from today, and no warning anywhere. + * + * A real Postgres is what makes this convincing: the summaries are built from + * rows the database actually holds, with the column genuinely NULL, and the + * tile gate is the same pure resolver the page renders through. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +import { resolveGlucoseTiles } from "@/components/dashboard/dashboard-gates"; +import type { DataSummary } from "@/lib/analytics/trends"; + +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), +})); + +interface AnalyticsEnvelope { + data: { + glucoseByContext: Record | null; + }; + error: null; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const READINGS = 20; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seedUntaggedGlucose() { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "glucose-untagged", + email: "glucose-untagged@example.test", + role: "USER", + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + // 20 readings, one per day, newest first at 104 mg/dL. Every one of them + // arrives exactly as the reporter's sync writes them: no meal-time context. + const now = Date.now(); + await prisma.measurement.createMany({ + data: Array.from({ length: READINGS }, (_, i) => ({ + userId: user.id, + type: "BLOOD_GLUCOSE" as const, + value: 104 + i, + unit: "mg/dL", + source: "APPLE_HEALTH" as const, + measuredAt: new Date(now - i * DAY_MS), + glucoseContext: null, + })), + }); + return user; +} + +async function readAnalytics(): Promise { + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + expect(response.status).toBe(200); + return (await response.json()) as AnalyticsEnvelope; +} + +describe("dashboard glucose tile with untagged readings (#943)", () => { + it("stores the readings with a NULL context", async () => { + const user = await seedUntaggedGlucose(); + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: user.id, type: "BLOOD_GLUCOSE" }, + select: { glucoseContext: true }, + }); + + expect(rows).toHaveLength(READINGS); + expect(rows.every((row) => row.glucoseContext === null)).toBe(true); + }); + + it("reports the tile as eligible and carries the latest value", async () => { + await seedUntaggedGlucose(); + + const envelope = await readAnalytics(); + const byContext = envelope.data.glucoseByContext; + expect(byContext).not.toBeNull(); + + // The summaries carry the untagged bucket, and nothing else. + expect(Object.keys(byContext ?? {})).toEqual(["UNSPECIFIED"]); + const summary = byContext!.UNSPECIFIED; + expect(summary.count).toBe(READINGS); + expect(summary.latest).toBe(104); + expect(summary.avg30).not.toBeNull(); + + // …and the tile gate the dashboard renders through says "paint it". + const tiles = resolveGlucoseTiles(byContext ?? undefined); + expect(tiles).toHaveLength(1); + expect(tiles[0]).toMatchObject({ + bucket: "UNSPECIFIED", + labelKey: "targets.glucoseUnspecified", + }); + expect(tiles[0].summary.latest).toBe(104); + }); + + it("keeps a tagged reading in its own bucket beside the untagged ones", async () => { + const user = await seedUntaggedGlucose(); + await getPrismaClient().measurement.create({ + data: { + userId: user.id, + type: "BLOOD_GLUCOSE", + value: 92, + unit: "mg/dL", + source: "MANUAL", + measuredAt: new Date(), + glucoseContext: "FASTING", + }, + }); + + const envelope = await readAnalytics(); + const byContext = envelope.data.glucoseByContext ?? {}; + + expect(Object.keys(byContext).sort()).toEqual(["FASTING", "UNSPECIFIED"]); + expect(byContext.FASTING.count).toBe(1); + expect(byContext.UNSPECIFIED.count).toBe(READINGS); + expect(resolveGlucoseTiles(byContext).map((tile) => tile.bucket)).toEqual([ + "FASTING", + "UNSPECIFIED", + ]); + }); +});