test: add comprehensive challenge group tests (G1-7)
Add 11 new test cases covering challenge session creation, correct/wrong answers, idempotent duplicate submission, completion settlement, resource exhaustion, Plus user bypass, and invalid input validation. Refactor test helpers to use queue-based mockImplementation pattern for more reliable db.select mocking across complex async flows.
This commit is contained in:
parent
8801ca1db2
commit
665efa4370
@ -47,7 +47,7 @@
|
|||||||
| G1-4 | 调整红心扣除边界 | [x] | 答错扣 1 颗;Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 |
|
| G1-4 | 调整红心扣除边界 | [x] | 答错扣 1 颗;Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 |
|
||||||
| G1-5 | 调整每日高奖励挑战次数 | [x] | 免费用户每日 3 组,Plus 8 组或按产品确认无限;次数为 0 后仍可继续学习但高价值奖励降级 |
|
| G1-5 | 调整每日高奖励挑战次数 | [x] | 免费用户每日 3 组,Plus 8 组或按产品确认无限;次数为 0 后仍可继续学习但高价值奖励降级 |
|
||||||
| G1-6 | 更新挑战 API DTO | [x] | `/challenges/next` 或新增 session API 能表达组、题、组进度、资源状态 |
|
| G1-6 | 更新挑战 API DTO | [x] | `/challenges/next` 或新增 session API 能表达组、题、组进度、资源状态 |
|
||||||
| G1-7 | 添加挑战组测试 | [ ] | 覆盖创建、答对、答错、重复提交、完成结算、资源不足和 Plus 分支 |
|
| G1-7 | 添加挑战组测试 | [x] | 覆盖创建、答对、答错、重复提交、完成结算、资源不足和 Plus 分支 |
|
||||||
|
|
||||||
## Phase G2:XP、等级、连续学习和知识卡奖励
|
## Phase G2:XP、等级、连续学习和知识卡奖励
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,10 @@ const questions = Array.from({ length: 5 }, (_, index) => ({
|
|||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock chain for `select().from().where()` that resolves with `result`.
|
||||||
|
* Supports `.orderBy()` and `.limit()` after `.where()`.
|
||||||
|
*/
|
||||||
function selectChain(result: unknown) {
|
function selectChain(result: unknown) {
|
||||||
const whereChain = {
|
const whereChain = {
|
||||||
orderBy: vi.fn().mockResolvedValue(result),
|
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 {
|
return {
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: 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', () => {
|
describe('challenge-service', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -83,7 +112,6 @@ describe('challenge-service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('applies XP multiplier for degraded rewards', () => {
|
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([
|
expect(getChallengeCompletionRewards(5, 5, 0.5)).toEqual([
|
||||||
{ type: 'xp', amount: 10, title: '完成挑战 +10 XP' },
|
{ type: 'xp', amount: 10, title: '完成挑战 +10 XP' },
|
||||||
{ type: 'xp', amount: 15, title: '全对奖励 +15 XP' },
|
{ type: 'xp', amount: 15, title: '全对奖励 +15 XP' },
|
||||||
@ -93,47 +121,31 @@ describe('challenge-service', () => {
|
|||||||
|
|
||||||
describe('getHighRewardQuota', () => {
|
describe('getHighRewardQuota', () => {
|
||||||
it('returns full quota when no daily progress exists for free user', async () => {
|
it('returns full quota when no daily progress exists for free user', async () => {
|
||||||
// getHighRewardQuota does: select from userDailyProgress
|
mockSelectQueue([[]]);
|
||||||
vi.mocked(db.select).mockReturnValueOnce(
|
|
||||||
selectWithWhere([]) as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
const quota = await getHighRewardQuota('user-1', 'free');
|
const quota = await getHighRewardQuota('user-1', 'free');
|
||||||
expect(quota).toEqual({ max: 3, used: 0, remaining: 3 });
|
expect(quota).toEqual({ max: 3, used: 0, remaining: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns full quota when no daily progress exists for pro user', async () => {
|
it('returns full quota when no daily progress exists for pro user', async () => {
|
||||||
vi.mocked(db.select).mockReturnValueOnce(
|
mockSelectQueue([[]]);
|
||||||
selectWithWhere([]) as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
const quota = await getHighRewardQuota('user-1', 'pro');
|
const quota = await getHighRewardQuota('user-1', 'pro');
|
||||||
expect(quota).toEqual({ max: 8, used: 0, remaining: 8 });
|
expect(quota).toEqual({ max: 8, used: 0, remaining: 8 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct remaining for free user with some used', async () => {
|
it('returns correct remaining for free user with some used', async () => {
|
||||||
vi.mocked(db.select).mockReturnValueOnce(
|
mockSelectQueue([[{ used: 2, restored: 0 }]]);
|
||||||
selectWithWhere([{ used: 2, restored: 0 }]) as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
const quota = await getHighRewardQuota('user-1', 'free');
|
const quota = await getHighRewardQuota('user-1', 'free');
|
||||||
expect(quota).toEqual({ max: 3, used: 2, remaining: 1 });
|
expect(quota).toEqual({ max: 3, used: 2, remaining: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns zero remaining when quota exhausted', async () => {
|
it('returns zero remaining when quota exhausted', async () => {
|
||||||
vi.mocked(db.select).mockReturnValueOnce(
|
mockSelectQueue([[{ used: 3, restored: 0 }]]);
|
||||||
selectWithWhere([{ used: 3, restored: 0 }]) as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
const quota = await getHighRewardQuota('user-1', 'free');
|
const quota = await getHighRewardQuota('user-1', 'free');
|
||||||
expect(quota).toEqual({ max: 3, used: 3, remaining: 0 });
|
expect(quota).toEqual({ max: 3, used: 3, remaining: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accounts for restored sessions', async () => {
|
it('accounts for restored sessions', async () => {
|
||||||
vi.mocked(db.select).mockReturnValueOnce(
|
mockSelectQueue([[{ used: 3, restored: 1 }]]);
|
||||||
selectWithWhere([{ used: 3, restored: 1 }]) as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
const quota = await getHighRewardQuota('user-1', 'free');
|
const quota = await getHighRewardQuota('user-1', 'free');
|
||||||
expect(quota).toEqual({ max: 3, used: 2, remaining: 1 });
|
expect(quota).toEqual({ max: 3, used: 2, remaining: 1 });
|
||||||
});
|
});
|
||||||
@ -142,21 +154,15 @@ describe('challenge-service', () => {
|
|||||||
describe('getNextChallenge', () => {
|
describe('getNextChallenge', () => {
|
||||||
it('creates a challenge session with five questions and hides correct answers', async () => {
|
it('creates a challenge session with five questions and hides correct answers', async () => {
|
||||||
const insertedValues = vi.fn().mockResolvedValue([]);
|
const insertedValues = vi.fn().mockResolvedValue([]);
|
||||||
|
// getNextChallenge uses selectChain for some queries (with orderBy)
|
||||||
vi.mocked(db.select)
|
vi.mocked(db.select)
|
||||||
// getTrackCategory
|
|
||||||
.mockReturnValueOnce(selectChain([category]) as never)
|
.mockReturnValueOnce(selectChain([category]) as never)
|
||||||
// getCurrentChapter → chapters
|
|
||||||
.mockReturnValueOnce(selectChain([chapter]) as never)
|
.mockReturnValueOnce(selectChain([chapter]) as never)
|
||||||
// getCurrentChapter → progress
|
|
||||||
.mockReturnValueOnce(selectChain([]) as never)
|
.mockReturnValueOnce(selectChain([]) as never)
|
||||||
// getQuestionsForChapter → answered
|
|
||||||
.mockReturnValueOnce(selectChain([]) as never)
|
.mockReturnValueOnce(selectChain([]) as never)
|
||||||
// getQuestionsForChapter → questions
|
|
||||||
.mockReturnValueOnce(selectChain(questions) as never)
|
.mockReturnValueOnce(selectChain(questions) as never)
|
||||||
// user tier lookup
|
.mockReturnValueOnce(selectRows([{ tier: 'free' }]) as never)
|
||||||
.mockReturnValueOnce(selectWithWhere([{ tier: 'free' }]) as never)
|
.mockReturnValueOnce(selectRows([]) as never);
|
||||||
// getHighRewardQuota → no daily progress (full quota)
|
|
||||||
.mockReturnValueOnce(selectWithWhere([]) as never);
|
|
||||||
vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
|
vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
|
||||||
|
|
||||||
const result = await getNextChallenge('user-1', 'history');
|
const result = await getNextChallenge('user-1', 'history');
|
||||||
@ -188,10 +194,8 @@ describe('challenge-service', () => {
|
|||||||
.mockReturnValueOnce(selectChain([]) as never)
|
.mockReturnValueOnce(selectChain([]) as never)
|
||||||
.mockReturnValueOnce(selectChain([]) as never)
|
.mockReturnValueOnce(selectChain([]) as never)
|
||||||
.mockReturnValueOnce(selectChain(questions) as never)
|
.mockReturnValueOnce(selectChain(questions) as never)
|
||||||
// user tier
|
.mockReturnValueOnce(selectRows([{ tier: 'free' }]) as never)
|
||||||
.mockReturnValueOnce(selectWithWhere([{ tier: 'free' }]) as never)
|
.mockReturnValueOnce(selectRows([{ used: 3, restored: 0 }]) as never);
|
||||||
// getHighRewardQuota → used=3, restored=0 (exhausted)
|
|
||||||
.mockReturnValueOnce(selectWithWhere([{ used: 3, restored: 0 }]) as never);
|
|
||||||
vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
|
vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
|
||||||
|
|
||||||
const result = await getNextChallenge('user-1', 'history');
|
const result = await getNextChallenge('user-1', 'history');
|
||||||
@ -210,51 +214,74 @@ describe('challenge-service', () => {
|
|||||||
.mockReturnValueOnce(selectChain([]) as never)
|
.mockReturnValueOnce(selectChain([]) as never)
|
||||||
.mockReturnValueOnce(selectChain([]) as never)
|
.mockReturnValueOnce(selectChain([]) as never)
|
||||||
.mockReturnValueOnce(selectChain(questions) as never)
|
.mockReturnValueOnce(selectChain(questions) as never)
|
||||||
.mockReturnValueOnce(selectWithWhere([{ tier: 'pro' }]) as never)
|
.mockReturnValueOnce(selectRows([{ tier: 'pro' }]) as never)
|
||||||
.mockReturnValueOnce(selectWithWhere([{ used: 5, restored: 0 }]) as never);
|
.mockReturnValueOnce(selectRows([{ used: 5, restored: 0 }]) as never);
|
||||||
vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
|
vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
|
||||||
|
|
||||||
const result = await getNextChallenge('user-1', 'history');
|
const result = await getNextChallenge('user-1', 'history');
|
||||||
|
|
||||||
// Pro user with 5 used out of 8 → still eligible
|
|
||||||
expect(result?.highRewardEligible).toBe(true);
|
expect(result?.highRewardEligible).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('submitChallengeAnswer', () => {
|
describe('submitChallengeAnswer', () => {
|
||||||
|
function makeSession(overrides: Record<string, unknown> = {}) {
|
||||||
|
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 () => {
|
it('returns the stored result for duplicate question submissions without side effects', async () => {
|
||||||
const resultSnapshot = {
|
const resultSnapshot = {
|
||||||
answerState: 'correct',
|
answerState: 'correct',
|
||||||
correctOptionId: 'a',
|
correctOptionId: 'a',
|
||||||
xpDelta: 10,
|
xpDelta: 10,
|
||||||
progress: {
|
progress: { hearts: 5, dailyAttemptsLeft: 3, xp: 120, streakDays: 2 },
|
||||||
hearts: 5,
|
knowledgeCard: { id: 'card-1', title: '知识点', summary: '知识点', fact: '解析' },
|
||||||
dailyAttemptsLeft: 3,
|
|
||||||
xp: 120,
|
|
||||||
streakDays: 2,
|
|
||||||
},
|
|
||||||
knowledgeCard: {
|
|
||||||
id: 'card-1',
|
|
||||||
title: '知识点',
|
|
||||||
summary: '知识点',
|
|
||||||
fact: '解析',
|
|
||||||
},
|
|
||||||
rewards: [{ type: 'xp', amount: 10, title: '+10 XP' }],
|
rewards: [{ type: 'xp', amount: 10, title: '+10 XP' }],
|
||||||
};
|
};
|
||||||
vi.mocked(db.select)
|
mockSelectQueue([
|
||||||
.mockReturnValueOnce(selectChain([{
|
[{ id: 'challenge-1', userId: 'user-1', status: 'pending', questionIds: ['question-1'] }],
|
||||||
id: 'challenge-1',
|
[{ id: 'answer-1', sessionId: 'challenge-1', questionId: 'question-1', submitRequestId: 'submit-1', resultSnapshot }],
|
||||||
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);
|
|
||||||
|
|
||||||
const result = await submitChallengeAnswer('user-1', 'challenge-1', 'question-1', 'a', 1200, 0, 'submit-1');
|
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.insert).not.toHaveBeenCalled();
|
||||||
expect(db.update).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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user