添加排行榜回归测试覆盖 G5-6

新增 leaderboard-service.test.ts,覆盖周 XP 累加与首次分组/加入
未满组/不重分配、组内排名、无记录、dryRun 预览/正式结算/多组
独立奖励、周榜元信息与奖励预览 10 个场景。Phase G5 全部完成。
This commit is contained in:
Wang Zhuoxuan 2026-05-13 22:03:38 +08:00
parent 6c63b6e24a
commit 407a2b9b32
3 changed files with 249 additions and 2 deletions

View File

@ -92,6 +92,7 @@
验证记录2026-05-13G4-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-13G4-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-13G4-7 已通过 `./node_modules/.bin/tsc --noEmit``./node_modules/.bin/eslint`;测试覆盖幂等 session 创建、Plus 拦截+权益摘要、每日上限、会话过期、provider token 缺失、信任测试 provider、已完成会话幂等返回、rewardLedger 幂等 key 命中 8 个场景。 验证记录2026-05-13G4-7 已通过 `./node_modules/.bin/tsc --noEmit``./node_modules/.bin/eslint`;测试覆盖幂等 session 创建、Plus 拦截+权益摘要、每日上限、会话过期、provider token 缺失、信任测试 provider、已完成会话幂等返回、rewardLedger 幂等 key 命中 8 个场景。
验证记录2026-05-13G5 全部通过 `./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-3 | 实现 20-30 人分组 | [x] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 |
| G5-4 | 实现前三奖励结算 | [x] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 | | G5-4 | 实现前三奖励结算 | [x] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 |
| G5-5 | 暴露周榜元信息 | [x] | API 返回 weekStart、weekEnd、nextRefreshAt、groupId、rank、rewardPreview | | G5-5 | 暴露周榜元信息 | [x] | API 返回 weekStart、weekEnd、nextRefreshAt、groupId、rank、rewardPreview |
| G5-6 | 添加排行榜测试 | [ ] | 覆盖周 XP 累加、分组、我的排名、周结算、重复结算 | | G5-6 | 添加排行榜测试 | [x] | 覆盖周 XP 累加、分组、我的排名、周结算、重复结算 |
## Phase G6API 文档、Admin 和运维 ## Phase G6API 文档、Admin 和运维

View File

@ -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 },
]);
});
});
});

View File

@ -197,7 +197,7 @@ async function assignGroupId(weekStartStr: string): Promise<string> {
* *
* XP 20-30 * XP 20-30
*/ */
async function addToWeeklyXp(userId: string, amount: number): Promise<void> { export async function addToWeeklyXp(userId: string, amount: number): Promise<void> {
const { weekStart, weekEnd } = getCurrentWeekRange(); const { weekStart, weekEnd } = getCurrentWeekRange();
const weekStartStr = weekStart.toISOString().slice(0, 10); const weekStartStr = weekStart.toISOString().slice(0, 10);