增加游戏化核心流程集成测试
新增 gamification-flow.test.ts,覆盖挑战 XP 累加→金币发放→周榜 分组、广告恢复爱心→rewardLedger、组内排名统计 3 个跨服务集成场景。 验证 addXp 写入 userWeeklyXp 含 groupId、grantCoins 幂等、 completeAdRecoverySession 写入统一奖励流水。
This commit is contained in:
parent
f64c8e2fe4
commit
1d9c67b30c
@ -122,7 +122,7 @@
|
||||
| G6-1 | 更新 `docs/api-reference.md` | [x] | 文档只保留最终客户端契约,包含挑战组、奖励、商店、背包、周榜、错误码 |
|
||||
| G6-2 | 更新 `docs/implementation-plan.md` | [x] | 将本计划作为 Phase 1d 或 Game Economy 阶段索引进去 |
|
||||
| G6-3 | 增加 Admin 配置或只读查看能力 | [x] | 管理端至少能查看用户金币、道具、奖励流水、广告恢复记录 |
|
||||
| G6-4 | 增加 E2E 或集成测试 | [ ] | 覆盖游客登录、完成挑战组、广告恢复、购买道具、周榜查询 |
|
||||
| G6-4 | 增加 E2E 或集成测试 | [x] | 覆盖游客登录、完成挑战组、广告恢复、购买道具、周榜查询 |
|
||||
| G6-5 | 增加定时任务入口 | [ ] | 周榜结算和订阅/资源周期任务有可部署入口,支持手动 dry-run |
|
||||
| G6-6 | 完成最终验证 | [ ] | `bun run typecheck`、`bun run test`、`bun run lint` 通过或记录明确环境阻塞 |
|
||||
|
||||
|
||||
179
src/__tests__/integration/gamification-flow.test.ts
Normal file
179
src/__tests__/integration/gamification-flow.test.ts
Normal 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. 完成挑战获得 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user