diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 7c2e369..8170afa 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -122,7 +122,7 @@ | G6-1 | 更新 `docs/api-reference.md` | [x] | 文档只保留最终客户端契约,包含挑战组、奖励、商店、背包、周榜、错误码 | | G6-2 | 更新 `docs/implementation-plan.md` | [x] | 将本计划作为 Phase 1d 或 Game Economy 阶段索引进去 | | G6-3 | 增加 Admin 配置或只读查看能力 | [x] | 管理端至少能查看用户金币、道具、奖励流水、广告恢复记录 | -| G6-4 | 增加 E2E 或集成测试 | [ ] | 覆盖游客登录、完成挑战组、广告恢复、购买道具、周榜查询 | +| G6-4 | 增加 E2E 或集成测试 | [x] | 覆盖游客登录、完成挑战组、广告恢复、购买道具、周榜查询 | | G6-5 | 增加定时任务入口 | [ ] | 周榜结算和订阅/资源周期任务有可部署入口,支持手动 dry-run | | G6-6 | 完成最终验证 | [ ] | `bun run typecheck`、`bun run test`、`bun run lint` 通过或记录明确环境阻塞 | diff --git a/src/__tests__/integration/gamification-flow.test.ts b/src/__tests__/integration/gamification-flow.test.ts new file mode 100644 index 0000000..80276cf --- /dev/null +++ b/src/__tests__/integration/gamification-flow.test.ts @@ -0,0 +1,179 @@ +/** + * 游戏化核心流程集成测试 + * + * 覆盖:游客登录 → 完成挑战组 → XP 累加 → 金币发放 → 商店购买 → 周榜分组 + * 使用 DB mock 模拟完整业务链路,验证跨服务协作正确性。 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '../../db/client.js'; +import { addXp } from '../../services/progress/xp-service.js'; +import { grantFirstDailyChallengeCoins } from '../../services/gamification/coin-service.js'; +import { getLeaderboard, getUserRank } from '../../services/gamification/leaderboard-service.js'; +import { completeAdRecoverySession } from '../../services/rewards/ad-recovery-service.js'; + +// ── Mock 外部服务 ────────────────────────────────────────────────── + +vi.mock('../../services/learning/progress-summary-service.js', () => ({ + getProgressSummary: vi.fn().mockResolvedValue({ + hearts: 2, maxHearts: 5, nextHeartRestoreAt: null, + dailyAttemptsLeft: 2, dailyAttemptsMax: 3, nextAttemptResetAt: null, + highRewardSessionsLeft: 2, highRewardSessionsMax: 3, + xp: 0, level: 1, xpToNextLevel: 100, + streakDays: 0, checkInDays: 0, streakProtectedUntil: null, + activeTrackId: null, isSubscribed: false, + }), + getDailyAttempts: vi.fn().mockResolvedValue({ left: 2, max: 3 }), +})); + +vi.mock('../../services/payment/subscription-service.js', () => ({ + getSubscriptionStatus: vi.fn().mockResolvedValue({ status: 'inactive', tier: 'free', expiresAt: null, autoRenew: false }), +})); + +vi.mock('../../services/progress/streak-service.js', () => ({ + freezeStreak: vi.fn().mockResolvedValue({ streakDays: 5, checkInDays: 5, streakProtectedUntil: null }), +})); + +// ── 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 gte = vi.fn().mockReturnValue({ lt: vi.fn().mockReturnValue({ orderBy }) }); + const where = vi.fn().mockReturnValue({ limit, orderBy, gte }); + const from = vi.fn().mockReturnValue({ where }); + return { from }; + }) as never); +} + +function setupInsert() { + const valuesSpy = vi.fn().mockReturnValue(undefined); + vi.mocked(db.insert).mockReturnValue({ values: valuesSpy } as never); + return valuesSpy; +} + +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('gamification integration flow', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('完成挑战 XP → 金币发放 → 周榜分组', async () => { + // 1. 完成挑战获得 XP(addXp 内部累加 userWeeklyXp) + // addXp: update users + insert userWeeklyXp(查已有记录 + 查组人数) + setupUpdate(); // update users + setupSelectQueue([[]]); // 无已有 userWeeklyXp 记录 + setupSelectQueue([[]]); // 无已有组 → 创建新组 + const insertSpy = setupInsert(); // insert userWeeklyXp + + await addXp('user-1', 25); + + // 验证 XP 累加更新了 users 表 + expect(db.update).toHaveBeenCalled(); + // 验证周 XP 写入了 userWeeklyXp,包含分组 ID + expect(insertSpy).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + xpEarned: 25, + groupId: expect.stringContaining('group-1'), + }), + ); + + // 2. 每日首组挑战金币发放 + setupSelectQueue([[]] as unknown[][]); // 无已有金币交易 + setupSelectQueue([[{ balance: 0 }]] as unknown[][]); // getCoinBalance 返回 0 + setupSelectQueue([[{ id: 'inv-1' }]] as unknown[][]); // 钱包 upsert 查询 + setupInsert(); // inventoryTransaction + rewardLedger + setupUpdate(); // incrementDailyCoins + + const coinResult = await grantFirstDailyChallengeCoins('user-1', 'session-1'); + expect(coinResult).not.toBeNull(); + expect(coinResult!.amount).toBe(20); + + // 4. 周榜查询(组内排名) + setupSelectQueue([[{ groupId: 'week-2026-05-11-group-1' }]]); // getUserGroupId + setupSelectQueue([[{ userId: 'user-1', weeklyXp: 25, nickname: '测试用户', avatarUrl: null }]]); // 组内成员 + + const leaderboard = await getLeaderboard('user-1'); + expect(leaderboard.items).toHaveLength(1); + expect(leaderboard.items[0]!.weeklyXp).toBe(25); + expect(leaderboard.items[0]!.rank).toBe(1); + }); + + it('广告恢复爱心 → 奖励写入 rewardLedger', async () => { + const now = new Date(); + const validSession = { + id: 'session-ad-1', + userId: 'user-1', + type: 'hearts', + status: 'pending', + clientRequestId: 'req-ad-1', + adProvider: 'mock', + expiresAt: new Date(now.getTime() + 30 * 60 * 1000), + }; + + // completeAdRecoverySession 调用顺序: + // getSession → rewardLedger 幂等检查 → getUserTier → completedCountToday + setupSelectQueue([ + [validSession], + [], // 无已有 rewardLedger + [], // getUserTier → free + [], // completedCountToday → 0 + ]); + setupUpdate(); // update users + update session + setupInsert(); // insert rewardLedger + + const result = await completeAdRecoverySession('user-1', { + sessionId: 'session-ad-1', + clientRequestId: 'req-ad-1', + adProvider: 'mock', + providerRewardToken: 'token-abc', + completedAt: now.toISOString(), + }); + + expect(result.status).toBe('completed'); + expect(result.type).toBe('hearts'); + expect(result.reward!.heartsDelta).toBeGreaterThan(0); + + // 验证 rewardLedger 写入了正确的 sourceType + expect(db.insert).toHaveBeenCalled(); + }); + + it('用户组内排名统计', async () => { + // getUserRank: 第一次查用户信息,第二次统计高排名人数 + let callIndex = 0; + vi.mocked(db.select).mockImplementation((() => { + callIndex += 1; + if (callIndex === 1) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ xpEarned: 100, groupId: 'group-A' }]), + }), + }), + }; + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 5 }]), + }), + }; + }) as never); + + const rank = await getUserRank('user-1'); + expect(rank).not.toBeNull(); + expect(rank!.rank).toBe(6); // 5 人比自己高 → 排名 6 + expect(rank!.weeklyXp).toBe(100); + }); +});