From 377bc8de4cdfc83833f650c7508f0f0193a915d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E4=BA=8E=E6=99=8F?= <3146347095@qq.com> Date: Sun, 28 Jun 2026 18:46:55 +0800 Subject: [PATCH] feat(api): enrich badges endpoint --- apps/api/src/routes/badges.cache.test.ts | 172 ++++++++++++++++++----- apps/api/src/routes/badges.ts | 83 ++++++++--- 2 files changed, 201 insertions(+), 54 deletions(-) diff --git a/apps/api/src/routes/badges.cache.test.ts b/apps/api/src/routes/badges.cache.test.ts index 4acb4107..02de6371 100644 --- a/apps/api/src/routes/badges.cache.test.ts +++ b/apps/api/src/routes/badges.cache.test.ts @@ -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"); @@ -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); }); }); diff --git a/apps/api/src/routes/badges.ts b/apps/api/src/routes/badges.ts index da5c0c61..6feba2f6 100644 --- a/apps/api/src/routes/badges.ts +++ b/apps/api/src/routes/badges.ts @@ -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 { - const { query } = await import("../db/index"); - const result = await query( - `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, + }; + }), + }); }); /**