增加游戏化核心流程集成测试

新增 gamification-flow.test.ts,覆盖挑战 XP 累加→金币发放→周榜
分组、广告恢复爱心→rewardLedger、组内排名统计 3 个跨服务集成场景。
验证 addXp 写入 userWeeklyXp 含 groupId、grantCoins 幂等、
completeAdRecoverySession 写入统一奖励流水。
This commit is contained in:
Wang Zhuoxuan 2026-05-13 22:33:47 +08:00
parent f64c8e2fe4
commit 1d9c67b30c
2 changed files with 180 additions and 1 deletions

View File

@ -122,7 +122,7 @@
| G6-1 | 更新 `docs/api-reference.md` | [x] | 文档只保留最终客户端契约,包含挑战组、奖励、商店、背包、周榜、错误码 | | G6-1 | 更新 `docs/api-reference.md` | [x] | 文档只保留最终客户端契约,包含挑战组、奖励、商店、背包、周榜、错误码 |
| G6-2 | 更新 `docs/implementation-plan.md` | [x] | 将本计划作为 Phase 1d 或 Game Economy 阶段索引进去 | | G6-2 | 更新 `docs/implementation-plan.md` | [x] | 将本计划作为 Phase 1d 或 Game Economy 阶段索引进去 |
| G6-3 | 增加 Admin 配置或只读查看能力 | [x] | 管理端至少能查看用户金币、道具、奖励流水、广告恢复记录 | | G6-3 | 增加 Admin 配置或只读查看能力 | [x] | 管理端至少能查看用户金币、道具、奖励流水、广告恢复记录 |
| G6-4 | 增加 E2E 或集成测试 | [ ] | 覆盖游客登录、完成挑战组、广告恢复、购买道具、周榜查询 | | G6-4 | 增加 E2E 或集成测试 | [x] | 覆盖游客登录、完成挑战组、广告恢复、购买道具、周榜查询 |
| G6-5 | 增加定时任务入口 | [ ] | 周榜结算和订阅/资源周期任务有可部署入口,支持手动 dry-run | | G6-5 | 增加定时任务入口 | [ ] | 周榜结算和订阅/资源周期任务有可部署入口,支持手动 dry-run |
| G6-6 | 完成最终验证 | [ ] | `bun run typecheck`、`bun run test`、`bun run lint` 通过或记录明确环境阻塞 | | G6-6 | 完成最终验证 | [ ] | `bun run typecheck`、`bun run test`、`bun run lint` 通过或记录明确环境阻塞 |

View File

@ -0,0 +1,179 @@
/**
*
*
* 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);
});
});