mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user