Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 137 additions & 35 deletions apps/api/src/routes/badges.cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
import { describe, it, expect, jest, beforeEach } from "@jest/globals";

const mockRedisGet = jest.fn();
const mockRedisSet = jest.fn();
const mockRedisDel = jest.fn();
import { describe, it, expect, vi, beforeEach } from "vitest";

const mocks = vi.hoisted(() => ({
redisGet: vi.fn(),
redisSet: vi.fn(),
redisDel: vi.fn(),
query: vi.fn(),
getUserBadges: vi.fn(),
}));

jest.mock("../lib/redis", () => ({
vi.mock("../lib/redis", () => ({
redis: {
get: mockRedisGet,
set: mockRedisSet,
del: mockRedisDel,
get: mocks.redisGet,
set: mocks.redisSet,
del: mocks.redisDel,
},
}));

const mockQuery = jest.fn();
jest.mock("../db/index", () => ({ query: mockQuery }));
vi.mock("../db/index", () => ({ query: mocks.query }));

vi.mock("../db/queries/badges", () => ({
getUserBadges: mocks.getUserBadges,
}));

vi.mock("../middleware/authenticate", () => ({
authenticate: (req: any, res: any, next: any) => {
if (req.headers.authorization === "Bearer admin-token") {
req.user = { sub: "admin-user", role: "admin" };
next();
return;
}

if (req.headers.authorization === "Bearer user-token") {
req.user = { sub: "user-1", role: "user" };
next();
return;
}

jest.mock("../middleware/authenticate", () => ({
authenticate: (_req: any, _res: any, next: any) => {
_req.user = { sub: "admin-user", role: "admin" };
res.status(401).json({ error: "No token provided" });
},
optionalAuth: (req: any, _res: any, next: any) => {
if (req.headers.authorization === "Bearer user-token") {
req.user = { sub: "user-1", role: "user" };
}
next();
},
}));

jest.mock("../middleware/require-admin", () => ({
vi.mock("../middleware/require-admin", () => ({
requireAdmin: (req: any, _res: any, next: any) => {
if (!req.user || req.user.role !== "admin") {
const err: any = new Error("Forbidden");
Expand All @@ -49,53 +73,131 @@ adminApp.use("/badges", router);
// Non-admin app
const noAuthApp = express();
noAuthApp.use(express.json());
noAuthApp.use((req: any, _res: any, next: any) => {
req.user = { sub: "user-1", role: "user" };
next();
});
noAuthApp.use("/badges", router);

const fakeBadges = [
{ id: "b1", slug: "first-win", name: "First Win", description: "Win first challenge", iconUrl: null },
{
id: "first_win",
slug: "first_win",
name: "First Win",
description: "Win first challenge",
iconUrl: "/badges/first-win.svg",
category: "challenge",
unlockCriteria: "Complete your first non-practice challenge.",
},
{
id: "league_gold",
slug: "league_gold",
name: "Gold Contender",
description: "You earned promotion to the Gold League.",
iconUrl: "/badges/league-gold.svg",
category: "league",
unlockCriteria: "Finish in the top 3 of your Silver league group.",
},
];

describe("badges cache", () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
mocks.redisSet.mockResolvedValue("OK");
mocks.getUserBadges.mockResolvedValue([]);
});

it("skips DB on second request within TTL (cache HIT)", async () => {
mockRedisGet.mockResolvedValueOnce(JSON.stringify(fakeBadges));
it("returns unauthenticated badge definitions without earned state from cache", async () => {
mocks.redisGet.mockResolvedValueOnce(JSON.stringify(fakeBadges));

const res = await request(app).get("/badges");
expect(res.status).toBe(200);
expect(mockQuery).not.toHaveBeenCalled();
expect(mocks.getUserBadges).not.toHaveBeenCalled();
expect(res.headers["x-cache"]).toBe("HIT");
expect(res.body.badges).toHaveLength(1);
expect(res.headers["cache-control"]).toBe("public, max-age=300");
expect(res.body.badges).toHaveLength(2);
expect(res.body.badges[0]).toMatchObject({
id: "first_win",
name: "First Win",
category: "challenge",
unlockCriteria: "Complete your first non-practice challenge.",
});
expect(res.body.badges[0]).not.toHaveProperty("earned");
expect(res.body.badges[0]).not.toHaveProperty("earnedAt");
});

it("calls DB on cache MISS and caches the result", async () => {
mockRedisGet.mockResolvedValueOnce(null);
mockQuery.mockResolvedValueOnce({ rows: fakeBadges });
mockRedisSet.mockResolvedValueOnce("OK");
it("builds definitions on cache MISS and caches for 5 minutes", async () => {
mocks.redisGet.mockResolvedValueOnce(null);

const res = await request(app).get("/badges");
expect(res.status).toBe(200);
expect(mockQuery).toHaveBeenCalledTimes(1);
expect(mockRedisSet).toHaveBeenCalledWith("badges:definitions", JSON.stringify(fakeBadges), "EX", 86400);
expect(res.body.badges.length).toBeGreaterThan(0);
expect(mocks.redisSet).toHaveBeenCalledWith(
"badges:definitions",
expect.any(String),
"EX",
300
);
expect(res.headers["x-cache"]).toBe("MISS");
});

it("annotates earned state when optional auth succeeds", async () => {
mocks.redisGet.mockResolvedValueOnce(JSON.stringify(fakeBadges));
mocks.getUserBadges.mockResolvedValueOnce([
{
id: "ub-1",
user_id: "user-1",
badge_slug: "league_gold",
awarded_at: "2026-06-28T10:00:00.000Z",
created_at: "2026-06-28T10:00:00.000Z",
updated_at: "2026-06-28T10:00:00.000Z",
},
]);

const res = await request(app).get("/badges").set("Authorization", "Bearer user-token");

expect(res.status).toBe(200);
expect(mocks.getUserBadges).toHaveBeenCalledWith("user-1");
expect(res.headers["cache-control"]).toBeUndefined();
expect(res.body.badges).toEqual([
expect.objectContaining({ slug: "first_win", earned: false, earnedAt: null }),
expect.objectContaining({
slug: "league_gold",
earned: true,
earnedAt: "2026-06-28T10:00:00.000Z",
}),
]);
});

it("filters badges by category", async () => {
mocks.redisGet.mockResolvedValueOnce(JSON.stringify(fakeBadges));

const res = await request(app).get("/badges?category=league");

expect(res.status).toBe(200);
expect(res.body.badges).toHaveLength(1);
expect(res.body.badges[0]).toMatchObject({ slug: "league_gold", category: "league" });
});

it("returns 200 with an empty array when no definitions are cached", async () => {
mocks.redisGet.mockResolvedValueOnce("[]");

const res = await request(app).get("/badges");

expect(res.status).toBe(200);
expect(res.body).toEqual({ badges: [] });
});

it("flush endpoint returns 204 and calls redis.del", async () => {
mockRedisDel.mockResolvedValueOnce(1);
mocks.redisDel.mockResolvedValueOnce(1);

const res = await request(adminApp).post("/badges/flush");
const res = await request(adminApp)
.post("/badges/flush")
.set("Authorization", "Bearer admin-token");
expect(res.status).toBe(204);
expect(mockRedisDel).toHaveBeenCalledWith("badges:definitions");
expect(mocks.redisDel).toHaveBeenCalledWith("badges:definitions");
});

it("flush endpoint returns 403 for non-admin", async () => {
const res = await request(noAuthApp).post("/badges/flush");
const res = await request(noAuthApp)
.post("/badges/flush")
.set("Authorization", "Bearer user-token");
expect(res.status).toBe(403);
});
});
83 changes: 64 additions & 19 deletions apps/api/src/routes/badges.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,90 @@
import { Router } from "express";
import { redis } from "../lib/redis";
import { authenticate } from "../middleware/authenticate";
import { authenticate, optionalAuth } from "../middleware/authenticate";
import { requireAdmin } from "../middleware/require-admin";
import { getUserBadges } from "../db/queries/badges";
import { BADGE_DEFINITIONS } from "../services/badges";

const router = Router();

const BADGES_CACHE_TTL_SEC = 86400;
const BADGES_CACHE_TTL_SEC = 300;
const BADGES_CACHE_KEY = "badges:definitions";

interface BadgeDefinition {
type BadgeCategory = "challenge" | "streak" | "league";

interface PublicBadgeDefinition {
id: string;
slug: string;
name: string;
description: string;
iconUrl?: string;
iconUrl: string;
category: BadgeCategory;
unlockCriteria: string;
}

function badgeCategory(slug: string): BadgeCategory {
if (slug.startsWith("league_")) return "league";
if (slug.startsWith("streak_")) return "streak";
return "challenge";
}

async function fetchBadgeDefinitionsFromDb(): Promise<BadgeDefinition[]> {
const { query } = await import("../db/index");
const result = await query<BadgeDefinition>(
`SELECT id, slug, name, description, icon_url AS "iconUrl" FROM badge_definitions ORDER BY name ASC`
);
return result.rows;
function publicBadgeDefinitions(): PublicBadgeDefinition[] {
return BADGE_DEFINITIONS.map((badge) => ({
id: badge.slug,
slug: badge.slug,
name: badge.name,
description: badge.description,
iconUrl: badge.iconUrl,
category: badgeCategory(badge.slug),
unlockCriteria: badge.criteria,
}));
}

function filterByCategory(
badges: PublicBadgeDefinition[],
category: unknown
): PublicBadgeDefinition[] {
if (typeof category !== "string" || category.trim() === "") return badges;
return badges.filter((badge) => badge.category === category.trim());
}

/**
* GET /api/badges
* Returns badge definitions. Cached in Redis for 24h.
* Returns badge definitions, optionally annotated with the current user's
* earned state when a valid bearer token is supplied.
*/
router.get("/", async (_req, res) => {
router.get("/", optionalAuth, async (req, res) => {
const cached = await redis.get(BADGES_CACHE_KEY);
if (cached !== null) {
res.setHeader("X-Cache", "HIT");
res.json({ badges: JSON.parse(cached) });
const definitions =
cached !== null ? (JSON.parse(cached) as PublicBadgeDefinition[]) : publicBadgeDefinitions();

if (cached === null) {
await redis.set(BADGES_CACHE_KEY, JSON.stringify(definitions), "EX", BADGES_CACHE_TTL_SEC);
}

const filtered = filterByCategory(definitions, req.query.category);

res.setHeader("X-Cache", cached !== null ? "HIT" : "MISS");

if (!req.user) {
res.setHeader("Cache-Control", "public, max-age=300");
res.json({ badges: filtered });
return;
}

const badges = await fetchBadgeDefinitionsFromDb();
await redis.set(BADGES_CACHE_KEY, JSON.stringify(badges), "EX", BADGES_CACHE_TTL_SEC);
res.setHeader("X-Cache", "MISS");
res.json({ badges });
const earned = await getUserBadges(req.user.sub);
const earnedMap = new Map(earned.map((badge) => [badge.badge_slug, badge]));

res.json({
badges: filtered.map((badge) => {
const record = earnedMap.get(badge.slug);
return {
...badge,
earned: !!record,
earnedAt: record?.awarded_at ?? null,
};
}),
});
});

/**
Expand Down