diff --git a/src/__tests__/api/analytics/since-cap.test.ts b/src/__tests__/api/analytics/since-cap.test.ts index d9f481d70..221f1342e 100644 --- a/src/__tests__/api/analytics/since-cap.test.ts +++ b/src/__tests__/api/analytics/since-cap.test.ts @@ -50,6 +50,19 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +// The slim slice now strips disabled-module types before answering, from +// the same map the snapshot builder gates on — so it resolves the module +// map. Spread the real module so only that call is stubbed. +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("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 03fcb3375..177950e2f 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -27,11 +27,24 @@ vi.mock("@/lib/db", () => ({ auditLog: { create: vi.fn(), }, + // The GET probes for an SDNN row to decide whether the `hrv` toggle is + // a dead switch for this account. Only reached when recovery is off. + measurement: { + findFirst: vi.fn(), + }, $transaction: vi.fn(), }, toJson: (v: unknown) => v, })); +// The GET reads one module flag to decide whether a toggle can do anything. +// Spread the real module so only that one call is stubbed — every other +// export stays live for whatever else pulls this in. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + isModuleEnabled: vi.fn(async () => true), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(), })); @@ -66,6 +79,7 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { annotate } from "@/lib/logging/context"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { DASHBOARD_WIDGET_IDS, DASHBOARD_IOS_ONLY_WIDGET_IDS, @@ -104,6 +118,10 @@ beforeEach(() => { __resetAuditDedupMemoForTests(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // `resetAllMocks` strips the factory's implementation, so state the + // default here beside the others: modules on, which is the shape every + // test below the dead-switch block assumes. + vi.mocked(isModuleEnabled).mockResolvedValue(true as never); vi.mocked(prisma.$transaction).mockImplementation((async ( callback: (tx: { user: typeof transactionUser }) => Promise, ) => callback({ user: transactionUser })) as never); @@ -1057,3 +1075,57 @@ describe("dashboard widgets — hero primary content", () => { expect(body.data.hero).toBeUndefined(); }); }); + +describe("a toggle that cannot do anything is not offered", () => { + // A switch that is on over a tile that can never paint, with nothing + // saying why, is the defect the HRV fallback exists to remove — so the + // GET tells the client which toggles are dead rather than leaving the + // Settings screen to draw one. + // + // `hrv` falls back to nightly RMSSD when an account has no SDNN, and + // RMSSD is recovery-owned. Recovery off + no SDNN = a dead switch. + function storeLayout() { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT), + } as never); + } + + async function getBody() { + const res = await callGet(); + expect(res.status).toBe(200); + return (await res.json()) as { + data: { unavailableWidgetIds?: string[] }; + }; + } + + it("marks hrv unavailable for a ring account with recovery off", async () => { + storeLayout(); + vi.mocked(isModuleEnabled).mockResolvedValue(false); + vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never); + + expect((await getBody()).data.unavailableWidgetIds).toEqual(["hrv"]); + }); + + it("leaves hrv available when the account has SDNN", async () => { + // SDNN is a plain vital no module owns, so the tile still works with + // recovery off. Taking this row away would be a new bug. + storeLayout(); + vi.mocked(isModuleEnabled).mockResolvedValue(false); + vi.mocked(prisma.measurement.findFirst).mockResolvedValue({ + id: "m1", + } as never); + + expect((await getBody()).data.unavailableWidgetIds).toBeUndefined(); + }); + + it("does not probe for SDNN at all when recovery is on", async () => { + // The probe is the only added read on this hot, cached route; it must + // not run for the common account. + storeLayout(); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(prisma.measurement.findFirst).mockClear(); + + expect((await getBody()).data.unavailableWidgetIds).toBeUndefined(); + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index e1fe467d9..13a1c37a7 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -14,7 +14,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { unavailableWidgetIds } from "@/lib/dashboard/widget-modules"; import { prisma, toJson } from "@/lib/db"; import { @@ -235,16 +235,22 @@ export const GET = apiHandler(async () => { // The SDNN probe is an index-only existence check // (`@@index([userId, type, measuredAt])`), not a summaries read; it runs // only when Recovery is off, so the common account pays nothing. - const modules = await resolveModuleMap(user.id); + // One flag, read the way this codebase reads one flag — not the whole + // module map. `hrv` is the only conditionally-dead widget and `recovery` + // is the only module that decides it. + const recoveryEnabled = await isModuleEnabled(user.id, "recovery"); let hasSdnn = true; - if (modules.recovery === false) { + if (!recoveryEnabled) { const sdnnRow = await prisma.measurement.findFirst({ where: { userId: user.id, type: "HEART_RATE_VARIABILITY" }, select: { id: true }, }); hasSdnn = sdnnRow !== null; } - const unavailable = unavailableWidgetIds(modules, { hasSdnn }); + const unavailable = unavailableWidgetIds( + { recovery: recoveryEnabled }, + { hasSdnn }, + ); return apiSuccess( unavailable.length > 0 ? { ...layout, unavailableWidgetIds: unavailable }