增加游戏化核心流程集成测试
新增 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-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` 通过或记录明确环境阻塞 |
|
||||||
|
|
||||||
|
|||||||
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