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:
Marc-André Bombeck committed 2026-06-15 21:25:40 +02:00
1 parent 3264dd2e86
commit 70402ea95a
10 files changed
+361 -5

No files matched your search

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.18.0 — `<AchievementUnlockNotifier>` honours the achievements module
* gate. When the module is disabled the polling query is never enabled,
* so no unlock toast can surface — the engagement surface disappears in
* lock-step with the page, the dashboard tile, and the 403 route.
*/
let mockUser: { modules?: Record<string, boolean> } | null = {};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: mockUser }),
}));
const useAchievementsQuery = vi.fn(
(_opts?: { enabled?: boolean }) => ({ data: undefined }),
);
vi.mock("@/lib/queries/use-achievements-query", () => ({
useAchievementsQuery: (opts?: { enabled?: boolean }) =>
useAchievementsQuery(opts),
}));
const toast = vi.fn();
vi.mock("sonner", () => ({ toast: (...args: unknown[]) => toast(...args) }));
import { AchievementUnlockNotifier } from "../achievement-unlock-notifier";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AchievementUnlockNotifier userId="user-1" />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockUser = {};
});
describe("<AchievementUnlockNotifier> — achievements module gate", () => {
it("disables the polling query when the module is off", () => {
mockUser = { modules: { achievements: false } };
expect(render()).toBe("");
expect(useAchievementsQuery).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
});
it("enables the polling query when the module is on (explicit true)", () => {
mockUser = { modules: { achievements: true } };
render();
expect(useAchievementsQuery).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true }),
);
});
it("enables the query by default when the module key is absent (default-on)", () => {
mockUser = { modules: {} };
render();
expect(useAchievementsQuery).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true }),
);
});
});
@@ -14,8 +14,10 @@ import type { AchievementProgress } from "@/lib/gamification/achievements";
* with their localized title + completedAt date.
*/
let mockUser: { modules?: Record<string, boolean> } | null = {};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
useAuth: () => ({ isAuthenticated: true, user: mockUser }),
}));
let mockData:
@@ -54,6 +56,7 @@ function render() {
beforeEach(() => {
vi.clearAllMocks();
mockIsPending = false;
mockUser = {};
});
const baseAchievement: AchievementProgress = {
@@ -130,6 +133,41 @@ describe("pickRecentUnlocks", () => {
});
});
describe("<RecentAchievementsCard> — achievements module gate", () => {
it("renders nothing when the achievements module is disabled", () => {
// v1.18.0 — an explicit `false` for the module hides the dashboard
// tile entirely so the surface disappears in lock-step with the page
// and the 403 route.
mockUser = { modules: { achievements: false } };
mockData = {
summary: {},
achievements: [
{ ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" },
],
metrics: {},
};
expect(render()).toBe("");
});
it("renders the tile when the module is enabled (explicit true)", () => {
mockUser = { modules: { achievements: true } };
mockData = {
summary: {},
achievements: [
{ ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" },
],
metrics: {},
};
expect(render()).toContain('data-slot="recent-achievements-card"');
});
it("renders the tile by default when the module key is absent (default-on)", () => {
mockUser = { modules: {} };
mockData = { summary: {}, achievements: [], metrics: {} };
expect(render()).toContain('data-slot="recent-achievements-card"');
});
});
describe("<RecentAchievementsCard>", () => {
it("renders a skeleton (no content, no empty state) while the query is pending", () => {
// The flash this guards against: before the query settles the card