duoqi-api/src/__tests__/integration/gamification-flow.test.ts
Wang Zhuoxuan 1d9c67b30c 增加游戏化核心流程集成测试
新增 gamification-flow.test.ts,覆盖挑战 XP 累加→金币发放→周榜
分组、广告恢复爱心→rewardLedger、组内排名统计 3 个跨服务集成场景。
验证 addXp 写入 userWeeklyXp 含 groupId、grantCoins 幂等、
completeAdRecoverySession 写入统一奖励流水。
2026-05-13 22:33:47 +08:00

180 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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