mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user