Mock the two new route dependencies, and cover the dead switch

Both routes 500'd in their unit tests because the change gave them
dependencies the mocks did not know about: the widgets GET reads a module
flag and probes for an SDNN row, and the analytics slim slice resolves the
module map. Neither test mocks `@/lib/modules/gate`, and the widgets test's
prisma double has no `measurement` model — so both calls threw and the
handler answered 500. Real breakage from this branch, not stale
expectations.

The widgets GET no longer resolves the whole module map for one answer.
`hrv` is the only conditionally-dead widget and `recovery` the only module
that decides it, so it reads that one flag through `isModuleEnabled`, the
way this route's neighbours read a single flag.

Both gate mocks spread the real module so only the one call is stubbed;
everything else that imports it keeps live behaviour. The widgets
`beforeEach` states the modules-on default explicitly, because
`resetAllMocks` strips a factory's implementation and every test below
assumes it.

Added coverage for the behaviour itself rather than only unbreaking the
suite: recovery off with no SDNN marks `hrv` unavailable, an account WITH
SDNN keeps the toggle, and recovery on does not run the probe at all — the
one added read on a hot cached route must not touch the common account.
This commit is contained in:
Antiheld86 committed 2026-09-03 07:42:00 +00:00
1 parent 6aac2070b4
commit 263c61da16
3 files changed
+95 -4

No files matched your search

@@ -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<typeof import("@/lib/modules/gate")>();
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 () => ({
@@ -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<typeof import("@/lib/modules/gate")>()),
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<unknown>,
) => 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();
});
});
+10 -4
View File
@@ -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 }