diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index d236a21..147ff13 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -47,7 +47,7 @@ | G1-4 | 调整红心扣除边界 | [x] | 答错扣 1 颗;Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 | | G1-5 | 调整每日高奖励挑战次数 | [x] | 免费用户每日 3 组,Plus 8 组或按产品确认无限;次数为 0 后仍可继续学习但高价值奖励降级 | | G1-6 | 更新挑战 API DTO | [x] | `/challenges/next` 或新增 session API 能表达组、题、组进度、资源状态 | -| G1-7 | 添加挑战组测试 | [ ] | 覆盖创建、答对、答错、重复提交、完成结算、资源不足和 Plus 分支 | +| G1-7 | 添加挑战组测试 | [x] | 覆盖创建、答对、答错、重复提交、完成结算、资源不足和 Plus 分支 | ## Phase G2:XP、等级、连续学习和知识卡奖励 diff --git a/src/__tests__/services/learning/challenge-service.test.ts b/src/__tests__/services/learning/challenge-service.test.ts index 270a271..93d9cda 100644 --- a/src/__tests__/services/learning/challenge-service.test.ts +++ b/src/__tests__/services/learning/challenge-service.test.ts @@ -42,6 +42,10 @@ const questions = Array.from({ length: 5 }, (_, index) => ({ updatedAt: null, })); +/** + * Creates a mock chain for `select().from().where()` that resolves with `result`. + * Supports `.orderBy()` and `.limit()` after `.where()`. + */ function selectChain(result: unknown) { const whereChain = { orderBy: vi.fn().mockResolvedValue(result), @@ -56,16 +60,41 @@ function selectChain(result: unknown) { }; } -function selectWithWhere(result: unknown) { +/** + * Returns a select mock object that resolves through `.from().where().limit()`. + */ +function selectRows(rows: unknown[]) { return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue(result), + limit: vi.fn().mockResolvedValue(rows), + then: (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve), }), }), }; } +function mockInsert() { + return { values: vi.fn().mockResolvedValue(undefined) } as never; +} + +function mockUpdate() { + return { set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never; +} + +/** + * Sets db.select to consume rows from a queue in order. + * Each call to db.select() returns a mock that resolves to the next queued rows. + */ +function mockSelectQueue(queue: unknown[][]) { + let index = 0; + vi.mocked(db.select).mockImplementation((() => { + const rows = index < queue.length ? queue[index]! : []; + index += 1; + return selectRows(rows); + }) as never); +} + describe('challenge-service', () => { beforeEach(() => { vi.clearAllMocks(); @@ -83,7 +112,6 @@ describe('challenge-service', () => { }); it('applies XP multiplier for degraded rewards', () => { - // multiplier = 0.5 as an example future value; current default is 1 expect(getChallengeCompletionRewards(5, 5, 0.5)).toEqual([ { type: 'xp', amount: 10, title: '完成挑战 +10 XP' }, { type: 'xp', amount: 15, title: '全对奖励 +15 XP' }, @@ -93,47 +121,31 @@ describe('challenge-service', () => { describe('getHighRewardQuota', () => { it('returns full quota when no daily progress exists for free user', async () => { - // getHighRewardQuota does: select from userDailyProgress - vi.mocked(db.select).mockReturnValueOnce( - selectWithWhere([]) as never, - ); - + mockSelectQueue([[]]); const quota = await getHighRewardQuota('user-1', 'free'); expect(quota).toEqual({ max: 3, used: 0, remaining: 3 }); }); it('returns full quota when no daily progress exists for pro user', async () => { - vi.mocked(db.select).mockReturnValueOnce( - selectWithWhere([]) as never, - ); - + mockSelectQueue([[]]); const quota = await getHighRewardQuota('user-1', 'pro'); expect(quota).toEqual({ max: 8, used: 0, remaining: 8 }); }); it('returns correct remaining for free user with some used', async () => { - vi.mocked(db.select).mockReturnValueOnce( - selectWithWhere([{ used: 2, restored: 0 }]) as never, - ); - + mockSelectQueue([[{ used: 2, restored: 0 }]]); const quota = await getHighRewardQuota('user-1', 'free'); expect(quota).toEqual({ max: 3, used: 2, remaining: 1 }); }); it('returns zero remaining when quota exhausted', async () => { - vi.mocked(db.select).mockReturnValueOnce( - selectWithWhere([{ used: 3, restored: 0 }]) as never, - ); - + mockSelectQueue([[{ used: 3, restored: 0 }]]); const quota = await getHighRewardQuota('user-1', 'free'); expect(quota).toEqual({ max: 3, used: 3, remaining: 0 }); }); it('accounts for restored sessions', async () => { - vi.mocked(db.select).mockReturnValueOnce( - selectWithWhere([{ used: 3, restored: 1 }]) as never, - ); - + mockSelectQueue([[{ used: 3, restored: 1 }]]); const quota = await getHighRewardQuota('user-1', 'free'); expect(quota).toEqual({ max: 3, used: 2, remaining: 1 }); }); @@ -142,21 +154,15 @@ describe('challenge-service', () => { describe('getNextChallenge', () => { it('creates a challenge session with five questions and hides correct answers', async () => { const insertedValues = vi.fn().mockResolvedValue([]); + // getNextChallenge uses selectChain for some queries (with orderBy) vi.mocked(db.select) - // getTrackCategory .mockReturnValueOnce(selectChain([category]) as never) - // getCurrentChapter → chapters .mockReturnValueOnce(selectChain([chapter]) as never) - // getCurrentChapter → progress .mockReturnValueOnce(selectChain([]) as never) - // getQuestionsForChapter → answered .mockReturnValueOnce(selectChain([]) as never) - // getQuestionsForChapter → questions .mockReturnValueOnce(selectChain(questions) as never) - // user tier lookup - .mockReturnValueOnce(selectWithWhere([{ tier: 'free' }]) as never) - // getHighRewardQuota → no daily progress (full quota) - .mockReturnValueOnce(selectWithWhere([]) as never); + .mockReturnValueOnce(selectRows([{ tier: 'free' }]) as never) + .mockReturnValueOnce(selectRows([]) as never); vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never); const result = await getNextChallenge('user-1', 'history'); @@ -188,10 +194,8 @@ describe('challenge-service', () => { .mockReturnValueOnce(selectChain([]) as never) .mockReturnValueOnce(selectChain([]) as never) .mockReturnValueOnce(selectChain(questions) as never) - // user tier - .mockReturnValueOnce(selectWithWhere([{ tier: 'free' }]) as never) - // getHighRewardQuota → used=3, restored=0 (exhausted) - .mockReturnValueOnce(selectWithWhere([{ used: 3, restored: 0 }]) as never); + .mockReturnValueOnce(selectRows([{ tier: 'free' }]) as never) + .mockReturnValueOnce(selectRows([{ used: 3, restored: 0 }]) as never); vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never); const result = await getNextChallenge('user-1', 'history'); @@ -210,51 +214,74 @@ describe('challenge-service', () => { .mockReturnValueOnce(selectChain([]) as never) .mockReturnValueOnce(selectChain([]) as never) .mockReturnValueOnce(selectChain(questions) as never) - .mockReturnValueOnce(selectWithWhere([{ tier: 'pro' }]) as never) - .mockReturnValueOnce(selectWithWhere([{ used: 5, restored: 0 }]) as never); + .mockReturnValueOnce(selectRows([{ tier: 'pro' }]) as never) + .mockReturnValueOnce(selectRows([{ used: 5, restored: 0 }]) as never); vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never); const result = await getNextChallenge('user-1', 'history'); - // Pro user with 5 used out of 8 → still eligible expect(result?.highRewardEligible).toBe(true); }); }); describe('submitChallengeAnswer', () => { + function makeSession(overrides: Record = {}) { + return { + id: 'challenge-1', + userId: 'user-1', + status: 'in_progress', + questionIds: ['q-1', 'q-2', 'q-3', 'q-4', 'q-5'], + totalQuestions: 5, + answeredCount: 0, + correctCount: 0, + highRewardEligible: 1, + ...overrides, + }; + } + + const testQuestion = { + id: 'q-1', + stem: { text: '测试题' }, + contentType: 'text', + correctAnswer: '正确答案', + distractors: ['干扰项1', '干扰项2'], + categoryId: 'history', + difficulty: 1, + dynamicDifficulty: null, + source: 'system', + creatorId: null, + status: 'published', + stats: { timesAnswered: 0, correctRate: 0, avgTimeMs: 0 }, + createdAt: null, + updatedAt: null, + }; + + const knowledgeCardRow = { + id: 'card-1', + questionId: 'q-1', + summary: '知识点摘要', + deepDive: '深入解析', + }; + + const freeUserRow = { + id: 'user-1', tier: 'free', xpTotal: 100, activeTrackId: null, + dailyAttemptsLeft: 5, dailyAttemptsDate: new Date().toISOString(), + checkInDays: 1, lastCheckInDate: null, streakProtectedUntil: null, + }; + it('returns the stored result for duplicate question submissions without side effects', async () => { const resultSnapshot = { answerState: 'correct', correctOptionId: 'a', xpDelta: 10, - progress: { - hearts: 5, - dailyAttemptsLeft: 3, - xp: 120, - streakDays: 2, - }, - knowledgeCard: { - id: 'card-1', - title: '知识点', - summary: '知识点', - fact: '解析', - }, + progress: { hearts: 5, dailyAttemptsLeft: 3, xp: 120, streakDays: 2 }, + knowledgeCard: { id: 'card-1', title: '知识点', summary: '知识点', fact: '解析' }, rewards: [{ type: 'xp', amount: 10, title: '+10 XP' }], }; - vi.mocked(db.select) - .mockReturnValueOnce(selectChain([{ - id: 'challenge-1', - userId: 'user-1', - status: 'pending', - questionIds: ['question-1'], - }]) as never) - .mockReturnValueOnce(selectChain([{ - id: 'answer-1', - sessionId: 'challenge-1', - questionId: 'question-1', - submitRequestId: 'submit-1', - resultSnapshot, - }]) as never); + mockSelectQueue([ + [{ id: 'challenge-1', userId: 'user-1', status: 'pending', questionIds: ['question-1'] }], + [{ id: 'answer-1', sessionId: 'challenge-1', questionId: 'question-1', submitRequestId: 'submit-1', resultSnapshot }], + ]); const result = await submitChallengeAnswer('user-1', 'challenge-1', 'question-1', 'a', 1200, 0, 'submit-1'); @@ -262,5 +289,241 @@ describe('challenge-service', () => { expect(db.insert).not.toHaveBeenCalled(); expect(db.update).not.toHaveBeenCalled(); }); + + it('throws NotFoundError when session does not exist', async () => { + mockSelectQueue([[]]); + + await expect( + submitChallengeAnswer('user-1', 'nonexistent', 'q-1', 'a', 1000), + ).rejects.toThrow('Challenge'); + }); + + it('throws ValidationError when session is already completed', async () => { + mockSelectQueue([[makeSession({ status: 'completed' })]]); + + await expect( + submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'a', 1000), + ).rejects.toThrow('not accepting answers'); + }); + + it('throws ValidationError when question is not in session', async () => { + mockSelectQueue([[makeSession()], []]); + + await expect( + submitChallengeAnswer('user-1', 'challenge-1', 'unknown-question', 'a', 1000), + ).rejects.toThrow('does not belong'); + }); + + it('awards XP for a correct answer', async () => { + mockSelectQueue([ + [makeSession()], // session + [], // no existing answer + [testQuestion], // question + [{ id: 'up-1' }], // getCorrectAnswersToday + [freeUserRow], // getResourceUser (getProgressSummary) + [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts + [{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak + [], // getSubscriptionStatus + [freeUserRow], // getDailyAttempts + [{ used: 0, restored: 0 }], // getHighRewardQuota + [knowledgeCardRow], // getKnowledgeCard + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert()); + vi.mocked(db.update).mockReturnValue(mockUpdate()); + + const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'c', 1200, 0); + + expect(result.answerState).toBe('correct'); + expect(result.correctOptionId).toBe('c'); + expect(result.xpDelta).toBe(10); + expect(result.rewards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'xp', amount: 10 }), + ]), + ); + expect(result.knowledgeCard.id).toBe('card-1'); + }); + + it('deducts a heart for a wrong answer', async () => { + const userAfter = { ...freeUserRow, dailyAttemptsLeft: 4 }; + mockSelectQueue([ + [makeSession()], // session + [], // no existing answer + [testQuestion], // question + [{ tier: 'free', heartsRemaining: 3 }], // deductHeart: user + [{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }], // isNewUserProtected + [freeUserRow], // deductDailyAttempt → getResourceUser + [userAfter], // getResourceUser (getProgressSummary) + [{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }], // getHearts + [{ checkInDays: 0, lastCheckInDate: null }], // calculateStreak + [], // getSubscriptionStatus + [userAfter], // getDailyAttempts + [{ used: 0, restored: 0 }], // getHighRewardQuota + [knowledgeCardRow], // getKnowledgeCard + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert()); + vi.mocked(db.update).mockReturnValue(mockUpdate()); + + const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'b', 2000, 0); + + expect(result.answerState).toBe('wrong'); + expect(result.xpDelta).toBe(0); + expect(db.update).toHaveBeenCalled(); + }); + + it('throws ValidationError when hearts are exhausted on wrong answer', async () => { + mockSelectQueue([ + [makeSession()], // session + [], // no existing answer + [testQuestion], // question + [{ tier: 'free', heartsRemaining: 0 }], // deductHeart: user + [{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }], // isNewUserProtected + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert()); + vi.mocked(db.update).mockReturnValue(mockUpdate()); + + await expect( + submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'b', 2000, 0), + ).rejects.toThrow('红心已用完'); + }); + + it('does not block Plus users when hearts are depleted', async () => { + const proUserRow = { ...freeUserRow, tier: 'pro', xpTotal: 200, dailyAttemptsLeft: 10 }; + const proUserAfter = { ...proUserRow, dailyAttemptsLeft: 9 }; + mockSelectQueue([ + [makeSession()], // session + [], // no existing answer + [testQuestion], // question + [{ tier: 'pro', heartsRemaining: 99 }], // deductHeart: pro user + [proUserRow], // deductDailyAttempt → getResourceUser + [proUserAfter], // getResourceUser (getProgressSummary) + [{ tier: 'pro', heartsRemaining: 99, heartsLastRestore: null }], // getHearts + [{ checkInDays: 0, lastCheckInDate: null }], // calculateStreak + [], // getSubscriptionStatus + [proUserAfter], // getDailyAttempts + [{ used: 0, restored: 0 }], // getHighRewardQuota + [knowledgeCardRow], // getKnowledgeCard + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert()); + vi.mocked(db.update).mockReturnValue(mockUpdate()); + + const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'b', 2000, 0); + + expect(result.answerState).toBe('wrong'); + expect(result.progress.hearts).toBe(99); + }); + + it('triggers completion settlement on the last question', async () => { + const userAfterXp = { ...freeUserRow, xpTotal: 150 }; + mockSelectQueue([ + [makeSession({ answeredCount: 4, correctCount: 4 })], // session + [], // no existing answer + [testQuestion], // question (but we submit q-5) + [{ id: 'up-1' }], // getCorrectAnswersToday + // settleCompletedChallenge → getProgressSummary (before) + [freeUserRow], // getResourceUser + [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts + [{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak + [], // getSubscriptionStatus + [freeUserRow], // getDailyAttempts + [{ used: 0, restored: 0 }], // getHighRewardQuota + // updateChapterProgress + [{ id: 'chapter-1', passThreshold: 3 }], + [], // no existing chapter progress + [], // no existing daily progress + // getProgressSummary (final) + [userAfterXp], + [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], + [{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], + [], + [userAfterXp], + [{ used: 1, restored: 0 }], + [knowledgeCardRow], + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert()); + vi.mocked(db.update).mockReturnValue(mockUpdate()); + + const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-5', 'c', 1500, 0); + + expect(result.answerState).toBe('correct'); + // 10 XP (correct answer) + 20 (complete) + 30 (perfect) = 60 + expect(result.xpDelta).toBe(60); + expect(result.rewards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'xp', amount: 10, title: '+10 XP' }), + expect.objectContaining({ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }), + expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 XP' }), + ]), + ); + }); + + it('gives completion XP but no perfect bonus when not all correct', async () => { + const userBefore = { ...freeUserRow, dailyAttemptsLeft: 4 }; + const userFinal = { ...freeUserRow, xpTotal: 120, dailyAttemptsLeft: 4 }; + mockSelectQueue([ + [makeSession({ answeredCount: 4, correctCount: 3 })], // session + [], // no existing answer + [testQuestion], // question + // wrong answer path + [{ tier: 'free', heartsRemaining: 3 }], // deductHeart + [{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }], // isNewUserProtected + [freeUserRow], // deductDailyAttempt → getResourceUser + // settleCompletedChallenge → getProgressSummary (before) + [userBefore], + [{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }], + [{ checkInDays: 0, lastCheckInDate: null }], + [], + [userBefore], + [{ used: 0, restored: 0 }], + // updateChapterProgress + [{ id: 'chapter-1', passThreshold: 3 }], + [], + [], // updateDailyProgress + // getProgressSummary (final) + [userFinal], + [{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }], + [{ checkInDays: 0, lastCheckInDate: null }], + [], + [userFinal], + [{ used: 1, restored: 0 }], + [knowledgeCardRow], + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert()); + vi.mocked(db.update).mockReturnValue(mockUpdate()); + + const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-5', 'b', 2000, 0); + + expect(result.answerState).toBe('wrong'); + expect(result.xpDelta).toBe(20); + const rewardTitles = result.rewards.map((r) => r.title); + expect(rewardTitles).toContain('完成挑战 +20 XP'); + expect(rewardTitles).not.toContain(expect.stringContaining('全对')); + }); + + it('throws ValidationError for invalid selectedOptionId', async () => { + mockSelectQueue([ + [makeSession()], // session + [], // no existing answer + [testQuestion], // question + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert()); + vi.mocked(db.update).mockReturnValue(mockUpdate()); + + await expect( + submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'z', 1000, 0), + ).rejects.toThrow('Invalid selectedOptionId'); + }); + + it('throws NotFoundError when question does not exist in DB', async () => { + mockSelectQueue([ + [makeSession()], // session + [], // no existing answer + [], // question not found + ]); + + await expect( + submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'a', 1000, 0), + ).rejects.toThrow('Question'); + }); }); });