diff --git a/src/app/api/dashboard/summary/__tests__/module-gate.test.ts b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts new file mode 100644 index 000000000..19b944743 --- /dev/null +++ b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts @@ -0,0 +1,212 @@ +/** + * `GET /api/dashboard/summary` — module gate. + * + * This route builds its own payload instead of going through + * `readDashboardSnapshotCached`, so it never picked up the gate the shared + * snapshot builder applies: it emitted glucose and sleep cards on data + * presence alone while `/api/dashboard/snapshot` correctly withheld them for + * the very same account. + * + * OMIT, not 403 — the payload spans ten metric cards across several domains, + * and blanking an iOS dashboard because one module is off would be the wrong + * trade. Core vitals must keep flowing. + * + * Behavioural: the assertions read the emitted card list, so reverting the + * filter in the route turns them red. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +// The gate resolver runs for real; only its data sources are stubbed, so the +// module state is driven the way production drives it. +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + cycleProfile: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/modules/operator-availability", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/modules/operator-availability") + >(); + return { ...actual, getOperatorModuleAvailability: vi.fn() }; +}); +vi.mock("@/lib/feature-flags", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getAssistantFlags: vi.fn() }; +}); +// The heavy builder is stubbed: this test pins the GATE, not the aggregate +// (which has its own coverage). It returns one card per emitted kind. +vi.mock("@/lib/cache/server-cache", () => ({ + caches: { analytics: {} }, + cachedSwr: vi.fn(), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { cachedSwr } from "@/lib/cache/server-cache"; +import { MODULE_KEYS } from "@/lib/modules/gate"; +import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability"; +import { getAssistantFlags } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + displayName: null, + locale: "en", + timezone: "UTC", + }, +}; + +/** Every card kind the builder can emit, one card each. */ +const ALL_KINDS = [ + "weight", + "bloodPressure", + "pulse", + "bodyFat", + "glucose", + "sleep", + "steps", + "totalBodyWater", + "boneMass", + "oxygenSaturation", +] as const; + +function card(kind: string) { + return { + id: kind, + kind, + titleKey: `dashboard.metric.title.${kind}`, + latestValue: 1, + secondaryValue: null, + unitKey: `dashboard.metric.unit.${kind}`, + unit: null, + sleepStages: null, + sleepSourceDiscrepancy: null, + trend: "flat", + sparkline: [1], + updatedAt: new Date().toISOString(), + allTimeCount: 5, + lastSeenAt: new Date().toISOString(), + }; +} + +/** Drive the real gate through the persisted disabled-allowlist. */ +function setDisabledModules(disabled: string[]): void { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + gender: null, + disableCoach: false, + modulePreferencesJson: Object.fromEntries(disabled.map((k) => [k, false])), + } as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); +} + +async function emittedKinds(): Promise { + const res = await GET(new Request("http://localhost/api/dashboard/summary")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { metrics: Array<{ kind: string }> }; + }; + return body.data.metrics.map((m) => m.kind); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(getOperatorModuleAvailability).mockResolvedValue( + Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never, + ); + vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never); + vi.mocked(cachedSwr).mockImplementation((async () => ({ + greeting: { salutation: "hi", date: new Date().toISOString() }, + streak: { currentDays: 0, longest: 0, label: "" }, + compliance: { scheduledToday: 0, takenToday: 0 }, + highlightInsight: null, + metrics: ALL_KINDS.map(card), + sleepRhythm: null, + lastUpdated: new Date().toISOString(), + })) as never); +}); + +describe("GET /api/dashboard/summary — module gate", () => { + it("emits every card when no module is disabled", async () => { + setDisabledModules([]); + expect(await emittedKinds()).toEqual([...ALL_KINDS]); + }); + + it("drops the glucose card when the glucose module is off", async () => { + setDisabledModules(["glucose"]); + const kinds = await emittedKinds(); + + expect(kinds).not.toContain("glucose"); + // Everything else survives — proves an omission, not a refusal. + expect(kinds).toContain("weight"); + expect(kinds).toContain("sleep"); + expect(kinds).toHaveLength(ALL_KINDS.length - 1); + }); + + it("drops the sleep card when the sleep module is off", async () => { + setDisabledModules(["sleep"]); + const kinds = await emittedKinds(); + + expect(kinds).not.toContain("sleep"); + expect(kinds).toContain("glucose"); + expect(kinds).toHaveLength(ALL_KINDS.length - 1); + }); + + it("drops both when both modules are off", async () => { + setDisabledModules(["glucose", "sleep"]); + const kinds = await emittedKinds(); + + expect(kinds).not.toContain("glucose"); + expect(kinds).not.toContain("sleep"); + expect(kinds).toHaveLength(ALL_KINDS.length - 2); + }); + + it("never drops a core vital, whatever is disabled", async () => { + // Core metrics carry no SUMMARY_TYPE_MODULE entry and must always ship. + setDisabledModules([...MODULE_KEYS]); + const kinds = await emittedKinds(); + + for (const core of [ + "weight", + "bloodPressure", + "pulse", + "bodyFat", + "steps", + "totalBodyWater", + "boneMass", + "oxygenSaturation", + ]) { + expect(kinds).toContain(core); + } + }); + + it("gates the cached body, so a toggle takes effect on the next request", async () => { + // The analytics LRU is not evicted by a module toggle. The filter must + // therefore run on the way out, not inside the cached build. + setDisabledModules([]); + expect(await emittedKinds()).toContain("glucose"); + + // Same cached body, module now off. + setDisabledModules(["glucose"]); + expect(await emittedKinds()).not.toContain("glucose"); + }); +}); diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 466b89dc4..3bf6040e0 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -1,6 +1,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; +// Every module is on for the aggregate assertions below; the gate's own +// behaviour (which cards a disabled module drops) is pinned in the sibling +// `module-gate.test.ts`. +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => + Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])), + ), + }; +}); + vi.mock("@/lib/db", () => ({ prisma: { // v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index dbf4490f2..00b399f98 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -44,6 +44,12 @@ * subsequent iOS polls inside the window hit memory. Invalidation runs * via the existing `invalidateUserMeasurements` + the v1.4.38-extended * `invalidateUserMedications` hooks. + * + * Module gate: the emitted `metrics` array is filtered against the account's + * module map before it leaves the handler, so a card whose measurement type + * belongs to a disabled module never ships. The decision comes from the same + * map the shared snapshot builder uses (`disabledSummaryTypes`); the filter + * runs OUTSIDE the cache because a module toggle does not evict it. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; @@ -57,6 +63,8 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules"; import { summarizeSleepNights, reconstructSleepNights, @@ -364,7 +372,35 @@ export const GET = apiHandler(async () => { annotate, ); - return apiSuccess(body); + // ── module gate ─────────────────────────────────────────────────── + // This route builds its own payload instead of going through + // `readDashboardSnapshotCached`, so it never picked up the module gate + // the shared snapshot builder applies — it emitted glucose and sleep + // tiles on data presence alone, while `/api/dashboard/snapshot` correctly + // withheld them for the same account. + // + // OMIT, NOT 403 — matching the snapshot sibling and the sync feed. This is + // an aggregate of ten metric cards across several domains; refusing the + // whole request would blank the iOS dashboard because one module is off. + // The card simply does not appear, which is exactly what a disabled module + // means on every other dashboard surface. + // + // Applied AFTER the cache read, deliberately. The analytics LRU is keyed + // `|dashboard-summary` with a 60 s fresh / 1 h stale window, and + // toggling a module does not evict it — gating inside the builder would + // leave the disabled tile served for up to an hour after the user turned + // it off. Filtering the cached body makes the toggle effective on the very + // next request. + const modules = await resolveModuleMap(user.id); + const metrics = gateMetricCardsByModules(body.metrics, modules); + annotate({ + meta: { + metrics_emitted: metrics.length, + metrics_gated: body.metrics.length - metrics.length, + }, + }); + + return apiSuccess({ ...body, metrics }); }); interface SummaryBuilderContext { diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 5ce296350..82b63d4c2 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -118,7 +118,7 @@ export { } from "@/lib/dashboard/widget-modules"; import { WIDGET_MODULE_BY_ID, - SUMMARY_TYPE_MODULE, + disabledSummaryTypes, } from "@/lib/dashboard/widget-modules"; const GLUCOSE_CONTEXTS = [ @@ -965,10 +965,9 @@ function gateSummariesByModules( summaries: Record; lastSeenByType: Record; } { - const dropped = new Set(); - for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) { - if (moduleKey && modules[moduleKey] === false) dropped.add(type); - } + // The drop decision is shared with `GET /api/dashboard/summary` — see + // `disabledSummaryTypes`. Only the filtering below is snapshot-shaped. + const dropped = disabledSummaryTypes(modules); if (dropped.size === 0) return { summaries, lastSeenByType }; const outSummaries: Record = {}; for (const [type, summary] of Object.entries(summaries)) { diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index b03ddd973..ee12a8dfa 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -75,3 +75,70 @@ export const SUMMARY_TYPE_MODULE: Partial> = { // on the `nutrients` module like the rest of that store's surfaces. NUTRIENT_WATER: "nutrients", }; + +/** + * The summary types a module map turns off. Lifted out of + * `gateSummariesByModules` in `@/lib/dashboard/snapshot` so the two + * dashboard aggregates that both surface per-metric data — the snapshot + * builder and the iOS `GET /api/dashboard/summary` payload — decide from + * ONE map instead of each carrying its own copy. + * + * The two callers shape their payloads differently (the snapshot keys + * summaries by `MeasurementType`; the summary route emits an array of + * metric cards keyed by an iOS `MetricKind`), so what is shared is this + * decision, not the filtering itself. + */ +export function disabledSummaryTypes( + modules: Partial>, +): Set { + const dropped = new Set(); + for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) { + if (moduleKey && modules[moduleKey] === false) dropped.add(type); + } + return dropped; +} + +/** + * iOS `MetricKind` (the `kind` on a `GET /api/dashboard/summary` metric + * card) → the `MeasurementType` it is built from. The summary route emits + * cards by kind, but module membership is defined per measurement type in + * `SUMMARY_TYPE_MODULE` above; this map is the join between the two so the + * summary payload gates off the SAME source of truth as the snapshot rather + * than a second, drift-prone kind→module list. + * + * Kinds whose type carries no `SUMMARY_TYPE_MODULE` entry (weight, blood + * pressure, pulse, body fat, steps, body water, bone mass, SpO₂) are core + * vitals and always pass through — they are listed anyway so a reader can + * see the full emitted set and so a new card cannot be added without + * deciding which type backs it. + */ +export const SUMMARY_METRIC_TYPE_BY_KIND: Record = { + weight: "WEIGHT", + bloodPressure: "BLOOD_PRESSURE_SYS", + pulse: "PULSE", + bodyFat: "BODY_FAT", + glucose: "BLOOD_GLUCOSE", + sleep: "SLEEP_DURATION", + steps: "ACTIVITY_STEPS", + totalBodyWater: "TOTAL_BODY_WATER", + boneMass: "BONE_MASS", + oxygenSaturation: "OXYGEN_SATURATION", +}; + +/** + * Drop the metric cards whose backing measurement type belongs to a module + * the account turned off. A card whose kind is absent from + * `SUMMARY_METRIC_TYPE_BY_KIND` is kept — an unmapped kind is a core metric + * or a new one, and silently hiding it would be worse than surfacing it. + */ +export function gateMetricCardsByModules( + cards: ReadonlyArray, + modules: Partial>, +): T[] { + const dropped = disabledSummaryTypes(modules); + if (dropped.size === 0) return [...cards]; + return cards.filter((card) => { + const type = SUMMARY_METRIC_TYPE_BY_KIND[card.kind]; + return !type || !dropped.has(type); + }); +}