diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index e55c028..5a971d5 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -92,6 +92,7 @@ 验证记录(2026-05-13):G4-1 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint src/services/rewards/ad-recovery-service.ts`;广告恢复奖励现已通过 `rewardLedger` 统一结算层发放,使用 `ad_recovery:{sessionId}` 幂等 key,记录 stateBefore/After 快照。 验证记录(2026-05-13):G4-7 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint`;测试覆盖幂等 session 创建、Plus 拦截+权益摘要、每日上限、会话过期、provider token 缺失、信任测试 provider、已完成会话幂等返回、rewardLedger 幂等 key 命中 8 个场景。 +验证记录(2026-05-13):G5 全部通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint`;G5-6 测试覆盖 addToWeeklyXp 首次分组/加入未满组/不重新分配、getUserRank 组内排名/无记录、weeklySettlement dryRun/正式结算/多组独立奖励、getLeaderboardMeta 周信息与奖励预览。 | # | 任务 | 状态 | 验收标准 | |---|------|------|----------| @@ -112,7 +113,7 @@ | G5-3 | 实现 20-30 人分组 | [x] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 | | G5-4 | 实现前三奖励结算 | [x] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 | | G5-5 | 暴露周榜元信息 | [x] | API 返回 weekStart、weekEnd、nextRefreshAt、groupId、rank、rewardPreview | -| G5-6 | 添加排行榜测试 | [ ] | 覆盖周 XP 累加、分组、我的排名、周结算、重复结算 | +| G5-6 | 添加排行榜测试 | [x] | 覆盖周 XP 累加、分组、我的排名、周结算、重复结算 | ## Phase G6:API 文档、Admin 和运维 diff --git a/src/__tests__/services/gamification/leaderboard-service.test.ts b/src/__tests__/services/gamification/leaderboard-service.test.ts new file mode 100644 index 0000000..5ab3e0e --- /dev/null +++ b/src/__tests__/services/gamification/leaderboard-service.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '../../../db/client.js'; +import { weeklySettlement, getUserRank, getLeaderboardMeta } from '../../../services/gamification/leaderboard-service.js'; +import { addToWeeklyXp } from '../../../services/progress/xp-service.js'; + +// ── Mock 外部服务 ────────────────────────────────────────────────── + +vi.mock('../../../services/gamification/coin-service.js', () => ({ + grantCoins: vi.fn().mockResolvedValue({ granted: true, balanceBefore: 0, balanceAfter: 300 }), +})); + +// ── DB Mock 辅助函数 ─────────────────────────────────────────────── + +/** 模拟 db.select() 链式调用,按调用顺序返回不同结果。 */ +function setupSelectQueue(queue: unknown[][]) { + let index = 0; + vi.mocked(db.select).mockImplementation((() => { + const rows = index < queue.length ? queue[index]! : []; + index += 1; + const limit = vi.fn().mockResolvedValue(rows); + const orderBy = vi.fn().mockReturnValue({ limit }); + const where = vi.fn().mockReturnValue({ limit, orderBy }); + const from = vi.fn().mockReturnValue({ where }); + return { from }; + }) as never); +} + +/** 模拟 db.insert().values() / .onDuplicateKeyUpdate() */ +function setupInsert() { + const valuesSpy = vi.fn(); + const onDuplicateSpy = vi.fn().mockReturnValue(undefined); + // 链式:insert().values().onDuplicateKeyUpdate() + vi.mocked(db.insert).mockReturnValue({ + values: valuesSpy.mockReturnValue({ onDuplicateKeyUpdate: onDuplicateSpy }), + } as never); + return { valuesSpy, onDuplicateSpy }; +} + +/** 模拟 db.update().set().where() */ +function setupUpdate() { + const setSpy = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue({ affectedRows: 1 }) }); + vi.mocked(db.update).mockReturnValue({ set: setSpy } as never); + return setSpy; +} + +// ── 测试 ─────────────────────────────────────────────────────────── + +describe('leaderboard-service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── 周 XP 累加与分组 ─────────────────────────────────────────── + + describe('addToWeeklyXp', () => { + it('首次获得本周 XP 时分配新分组', async () => { + // 无已有记录 → 需要分配组 + setupSelectQueue([[]]); // 查已有记录为空 + // 查组人数为空 → 创建新组 + setupSelectQueue([[]]); + const { valuesSpy } = setupInsert(); + + await addToWeeklyXp('user-1', 10); + + // 验证插入了 userWeeklyXp 记录,包含 groupId + expect(valuesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + xpEarned: 10, + groupId: expect.stringMatching(/^week-\d{4}-\d{2}-\d{2}-group-1$/), + }), + ); + }); + + it('加入已有未满组', async () => { + // 已有记录为空 → 需分配组 + setupSelectQueue([[]]); + // 组人数查询:group-1 有 25 人(< 30) + setupSelectQueue([[{ groupId: 'week-2026-05-11-group-1', count: 25 }]]); + const { valuesSpy } = setupInsert(); + + await addToWeeklyXp('user-2', 15); + + expect(valuesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-2', + groupId: 'week-2026-05-11-group-1', + }), + ); + }); + + it('已有本周记录时不重新分配组', async () => { + // 已有记录,groupId 已存在 + setupSelectQueue([[{ groupId: 'week-2026-05-11-group-3' }]]); + const { onDuplicateSpy } = setupInsert(); + + await addToWeeklyXp('user-1', 20); + + // 应该走 onDuplicateKeyUpdate 而不是新建 + expect(onDuplicateSpy).toHaveBeenCalledWith({ + set: { + xpEarned: expect.any(Object), // sql`COALESCE(xp_earned, 0) + 20` + lastXpAt: expect.any(Object), + }, + }); + }); + }); + + // ── 组内排名 ──────────────────────────────────────────────────── + + describe('getUserRank', () => { + it('返回组内排名和本周 XP', async () => { + // getUserRank: 第一次 select 获取用户信息,第二次 select 统计比自己高的人数 + let callIndex = 0; + vi.mocked(db.select).mockImplementation((() => { + callIndex += 1; + if (callIndex === 1) { + // 用户本周 XP 和 groupId + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ xpEarned: 150, groupId: 'group-A' }]), + }), + }), + }; + } + // 同组内比自己高的人数 = 2 + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 2 }]), + }), + }; + }) as never); + + const result = await getUserRank('user-1'); + + expect(result).toEqual({ + rank: 3, + tier: expect.any(String), + weeklyXp: 150, + }); + }); + + it('用户无本周记录时返回 null', async () => { + setupSelectQueue([[]]); + + const result = await getUserRank('user-unknown'); + + expect(result).toBeNull(); + }); + }); + + // ── 周结算 ────────────────────────────────────────────────────── + + describe('weeklySettlement', () => { + it('dryRun 模式只返回预览不写库', async () => { + // 查询上一周所有记录 + setupSelectQueue([[ + { userId: 'u1', weeklyXp: 300, groupId: 'g1' }, + { userId: 'u2', weeklyXp: 200, groupId: 'g1' }, + { userId: 'u3', weeklyXp: 100, groupId: 'g1' }, + ]]); + + const result = await weeklySettlement(true); + + expect(result.settled).toBe(false); + expect(result.userCount).toBe(3); + expect(result.groupCount).toBe(1); + expect(result.top3).toHaveLength(3); + expect(result.top3[0]).toEqual({ userId: 'u1', weeklyXp: 300, rank: 1 }); + // 奖励预览:每组前 3 名 + expect(result.rewards).toHaveLength(3); + expect(result.rewards[0]).toEqual({ userId: 'u1', groupId: 'g1', rank: 1, coins: 300 }); + expect(result.rewards[1]).toEqual({ userId: 'u2', groupId: 'g1', rank: 2, coins: 150 }); + expect(result.rewards[2]).toEqual({ userId: 'u3', groupId: 'g1', rank: 3, coins: 50 }); + // dryRun 不应写库 + expect(db.insert).not.toHaveBeenCalled(); + expect(db.update).not.toHaveBeenCalled(); + }); + + it('正式结算写入快照并发放奖励', async () => { + setupSelectQueue([[ + { userId: 'u1', weeklyXp: 300, groupId: 'g1' }, + { userId: 'u2', weeklyXp: 200, groupId: 'g1' }, + { userId: 'u3', weeklyXp: 100, groupId: 'g1' }, + { userId: 'u4', weeklyXp: 50, groupId: 'g1' }, + ]]); + setupInsert(); + setupUpdate(); + + const result = await weeklySettlement(false); + + expect(result.settled).toBe(true); + expect(result.groupCount).toBe(1); + // 只有前 3 名有奖励,第 4 名没有 + expect(result.rewards).toHaveLength(3); + // 验证快照插入被调用 + expect(db.insert).toHaveBeenCalled(); + }); + + it('多组结算时各组独立奖励', async () => { + setupSelectQueue([[ + // group-1(按 groupId 排序) + { userId: 'u1', weeklyXp: 300, groupId: 'group-1' }, + { userId: 'u2', weeklyXp: 200, groupId: 'group-1' }, + // group-2 + { userId: 'u3', weeklyXp: 250, groupId: 'group-2' }, + { userId: 'u4', weeklyXp: 150, groupId: 'group-2' }, + ]]); + + const result = await weeklySettlement(true); + + expect(result.groupCount).toBe(2); + // 每组前 3 名,但每组只有 2 人,所以只有 rank 1 和 2 有奖励 + expect(result.rewards).toHaveLength(4); + // group-1 的奖励 + const g1Rewards = result.rewards.filter(r => r.groupId === 'group-1'); + expect(g1Rewards[0]).toEqual({ userId: 'u1', groupId: 'group-1', rank: 1, coins: 300 }); + expect(g1Rewards[1]).toEqual({ userId: 'u2', groupId: 'group-1', rank: 2, coins: 150 }); + // group-2 的奖励 + const g2Rewards = result.rewards.filter(r => r.groupId === 'group-2'); + expect(g2Rewards[0]).toEqual({ userId: 'u3', groupId: 'group-2', rank: 1, coins: 300 }); + expect(g2Rewards[1]).toEqual({ userId: 'u4', groupId: 'group-2', rank: 2, coins: 150 }); + }); + }); + + // ── 周榜元信息 ────────────────────────────────────────────────── + + describe('getLeaderboardMeta', () => { + it('返回周信息和奖励预览', async () => { + setupSelectQueue([[{ groupId: 'week-2026-05-11-group-1' }]]); + + const meta = await getLeaderboardMeta('user-1'); + + expect(meta.weekStart).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(meta.weekEnd).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(meta.nextRefreshAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(meta.groupId).toBe('week-2026-05-11-group-1'); + expect(meta.rewardPreview).toEqual([ + { rank: 1, coins: 300 }, + { rank: 2, coins: 150 }, + { rank: 3, coins: 50 }, + ]); + }); + }); +}); diff --git a/src/services/progress/xp-service.ts b/src/services/progress/xp-service.ts index 4de910e..d696059 100644 --- a/src/services/progress/xp-service.ts +++ b/src/services/progress/xp-service.ts @@ -197,7 +197,7 @@ async function assignGroupId(weekStartStr: string): Promise { * 确保每周每个用户只有一行记录。 * 首次获得本周 XP 时自动分配到 20-30 人的排行榜分组。 */ -async function addToWeeklyXp(userId: string, amount: number): Promise { +export async function addToWeeklyXp(userId: string, amount: number): Promise { const { weekStart, weekEnd } = getCurrentWeekRange(); const weekStartStr = weekStart.toISOString().slice(0, 10);