mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(modules): gate achievements behind the achievements module flag
When an account turns the achievements module off the whole gamification surface disappears. The route enforces the gate after auth and returns the 403 module.disabled envelope, so no badge evaluation, unlock persistence, or payload is produced. The achievements page, the dashboard recent-unlocks tile, and the unlock notifier read the resolved module map from GET /api/auth/me and render nothing / disable their query when the module is off. Default-on: an absent key reads as enabled, a stale /me payload coerces to an empty map so no surface vanishes on a partial-deploy rollback.
This commit is contained in:
1 parent
3264dd2e86
commit
70402ea95a
10 files changed
+361
-5
No files matched your search
@@ -19,6 +19,17 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
// v1.18.0 — this suite predates the achievements module gate and only
|
||||
// mocks the aggregation Prisma models. Pin the gate to "enabled" so it
|
||||
// stays focused on the hidden-Easter-egg redaction contract; the gate's
|
||||
// own enable/disable behaviour is covered in module-gate.test.ts.
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
// Plain async fn (not a `vi.fn`) so `vi.resetAllMocks()` in beforeEach
|
||||
// can't blank it out and flip the gate to a falsy decision.
|
||||
requireModuleEnabled: async () => ({ enabled: true }),
|
||||
MODULE_DISABLED_ERROR_CODE: "module.disabled",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
@@ -19,6 +19,16 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
// v1.18.0 — this suite predates the achievements module gate and only
|
||||
// mocks the aggregation Prisma models. Pin the gate to "enabled" (a plain
|
||||
// async fn so `vi.resetAllMocks()` can't blank it out) so the format
|
||||
// branches stay in scope; the gate's enable/disable behaviour is covered
|
||||
// in module-gate.test.ts.
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: async () => ({ enabled: true }),
|
||||
MODULE_DISABLED_ERROR_CODE: "module.disabled",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.18.0 — the achievements module gate on
|
||||
* `GET /api/gamification/achievements`.
|
||||
*
|
||||
* Disabled ⇒ a 403 `module.disabled` envelope, and the heavy badge
|
||||
* aggregation never runs (no Prisma reads, no unlock
|
||||
* persistence). This is what makes the surface DISAPPEAR for
|
||||
* an account that has turned the module off — the web page,
|
||||
* the dashboard tile, and the unlock notifier all gate the
|
||||
* same route and the same `/api/auth/me` module map.
|
||||
* Enabled ⇒ the route behaves exactly as before (200 + payload).
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
passkey: { findMany: vi.fn() },
|
||||
auditLog: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
userAchievement: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
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),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// The module gate is mocked directly so this test asserts the route's
|
||||
// branch on the gate decision, not the resolver internals (those have
|
||||
// their own unit coverage in src/lib/modules/__tests__/gate.test.ts).
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
MODULE_DISABLED_ERROR_CODE: "module.disabled",
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
createdAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
heightCm: null,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/gamification/achievements — achievements module gate", () => {
|
||||
it("returns the 403 module.disabled envelope when the module is off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError("Module \"achievements\" is not enabled", 403, {
|
||||
errorCode: "module.disabled",
|
||||
module: "achievements",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/gamification/achievements"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
meta: { errorCode: string; module: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta.errorCode).toBe("module.disabled");
|
||||
expect(body.meta.module).toBe("achievements");
|
||||
|
||||
// The disappearance is total: no badge aggregation ran.
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.userAchievement.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.userAchievement.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves the payload normally when the module is enabled", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/gamification/achievements"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { achievements: unknown[]; summary: unknown };
|
||||
};
|
||||
expect(body.data.achievements).toBeDefined();
|
||||
expect(body.data.summary).toBeDefined();
|
||||
expect(prisma.userAchievement.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still 401s before the module gate when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/gamification/achievements"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
// Auth is the outer guard — the module gate is never consulted.
|
||||
expect(requireModuleEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
@@ -469,6 +470,21 @@ function getCompliance80DaySeries(
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.18.0 — when the account has the achievements module turned off the
|
||||
// whole gamification surface disappears: no badge evaluation, no unlock
|
||||
// persistence, no payload. Returns the 403 `module.disabled` envelope
|
||||
// verbatim so the client (web + iOS) hides the page / dashboard tile /
|
||||
// unlock toast in lock-step with this refusal.
|
||||
const gate = await requireModuleEnabled(user.id, "achievements");
|
||||
if (!gate.enabled) {
|
||||
annotate({
|
||||
action: { name: "gamification.achievements" },
|
||||
meta: { moduleDisabled: true },
|
||||
});
|
||||
return gate.response;
|
||||
}
|
||||
|
||||
const formatParam = request.nextUrl.searchParams.get("format");
|
||||
const isIosFormat = formatParam === "ios";
|
||||
annotate({
|
||||
|
||||
Reference in new issue
Block a user