From 1e6f72b1c50395e2e930dfd063d9615fb62c07a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 01:27:42 +0200 Subject: [PATCH] Prove the achievements read writes nothing against a real pending unlock The assertion in the module-gate suite could not fail: that fixture's mood badges carry no completion date, so the result it drove had no pending unlock and "nothing was written" was true either way. Restoring the INSERT left it green. The case moves to its own file with the builder faked so the result always carries a pending unlock. Undoing the fix now turns it red. The disabled-module invariant the old case reached for lives with the write, in the sweep's suite. --- .../__tests__/module-gate.test.ts | 21 +-- .../__tests__/writes-nothing.test.ts | 125 ++++++++++++++++++ 2 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts diff --git a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts index 8e6fa9ed1..b892cd289 100644 --- a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts +++ b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts @@ -185,12 +185,7 @@ describe("GET /api/gamification/achievements — per-module badge skipping", () }, ]; - type Badge = { - id: string; - category: string; - metric: string; - unlocked: boolean; - }; + type Badge = { id: string; category: string; metric: string }; async function badgesFor( moduleMap: Record, @@ -222,14 +217,8 @@ describe("GET /api/gamification/achievements — per-module badge skipping", () expect(badges.length).toBeGreaterThan(0); }); - it("persists no unlock at all — a GET does not write", async () => { - // A mood badge unlocks at >=1 entry, so this read HAS an unlock to - // notice. It still writes nothing: the row that pins the date is the - // `achievement-unlock-sweep` job's, and a read carrying an INSERT is - // the side-effecting GET `api-handler.ts` warns against. The badge grid - // is unaffected — `unlocked` comes from the live metrics. - const badges = await badgesFor({ mood: true }); - expect(badges.some((b) => b.category === "mood" && b.unlocked)).toBe(true); - expect(prisma.userAchievement.createMany).not.toHaveBeenCalled(); - }); + // The "a disabled module never gets its badge pinned" invariant moved with + // the write itself — see `src/lib/jobs/__tests__/achievement-unlock-sweep.test.ts`. + // That this route persists nothing at all is proved in + // `writes-nothing.test.ts`, against a result that HAS a pending unlock. }); diff --git a/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts b/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts new file mode 100644 index 000000000..e600c2bd0 --- /dev/null +++ b/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts @@ -0,0 +1,125 @@ +/** + * `GET /api/gamification/achievements` is a read. + * + * It used to INSERT a `UserAchievement` row for every unlock it noticed, + * which is the side-effecting GET the MCP-audience note in `api-handler.ts` + * warns against. The evaluation still runs here; only the persistence moved + * to the `achievement-unlock-sweep` job. + * + * The builder is faked so the result ALWAYS carries a pending unlock. That is + * the point: an assertion that the route did not write is worth nothing + * against a fixture that had nothing to write, and the fixture in + * `module-gate.test.ts` turns out to be exactly that. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { userAchievement: { findMany: vi.fn(), createMany: 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: () => {}, + })), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + resolveModuleMap: vi.fn(async () => ({})), + MODULE_DISABLED_ERROR_CODE: "module.disabled", +})); + +const PENDING_UNLOCK = { + achievementId: "mood-first-entry", + unlockedAt: new Date("2026-03-02T08:00:00.000Z"), +}; + +vi.mock("@/lib/gamification/achievements-result", () => ({ + buildAchievementsResult: vi.fn(async () => ({ + summary: { unlockedCount: 1, totalCount: 1 }, + achievements: [ + { + id: "mood-first-entry", + category: "mood", + unlocked: true, + completedAt: "2026-03-02T08:00:00.000Z", + titleKey: "achievements.moodFirst.title", + descriptionKey: "achievements.moodFirst.description", + icon: "Smile", + progressPercent: 100, + points: 10, + target: 1, + current: 1, + isHidden: false, + }, + ], + metrics: {}, + pendingUnlocks: [PENDING_UNLOCK], + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { buildAchievementsResult } from "@/lib/gamification/achievements-result"; +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: "tester", + role: "USER" as const, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + locale: "en", + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + __resetAllCachesForTests(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +async function callGet() { + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + return { status: res.status, body: JSON.parse(await res.text()) }; +} + +describe("GET /api/gamification/achievements", () => { + it("has a pending unlock to notice — otherwise the case below is empty", async () => { + await callGet(); + const result = await vi.mocked(buildAchievementsResult).mock.results[0]! + .value; + expect(result.pendingUnlocks).toHaveLength(1); + }); + + it("persists nothing — a GET does not write", async () => { + const { status } = await callGet(); + expect(status).toBe(200); + expect(prisma.userAchievement.createMany).not.toHaveBeenCalled(); + }); + + it("still renders the unlocked badge, so the payload never depended on the row", async () => { + const { body } = await callGet(); + const achievements = (body.data as { achievements: Array }) + .achievements as Array<{ id: string; unlocked: boolean }>; + expect(achievements).toHaveLength(1); + expect(achievements[0]!.unlocked).toBe(true); + }); + + it("does not leak the internal pendingUnlocks carrier onto the wire", async () => { + const { body } = await callGet(); + expect(body.data).not.toHaveProperty("pendingUnlocks"); + }); +});