新增 gamification-flow.test.ts,覆盖挑战 XP 累加→金币发放→周榜 分组、广告恢复爱心→rewardLedger、组内排名统计 3 个跨服务集成场景。 验证 addXp 写入 userWeeklyXp 含 groupId、grantCoins 幂等、 completeAdRecoverySession 写入统一奖励流水。
180 lines
7.0 KiB
TypeScript
180 lines
7.0 KiB
TypeScript
/**
|
||
* 游戏化核心流程集成测试
|
||
*
|
||
* 覆盖:游客登录 → 完成挑战组 → 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);
|
||
});
|
||
});
|