/** * 游戏化核心流程集成测试 * * 覆盖:游客登录 → 完成挑战组 → 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); }); });