From eee2116633f2658250f82900d71171bdc68a01d4 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Wed, 13 May 2026 20:34:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=B9=BF=E5=91=8A=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= =?UTF-8?q?=20G4-7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ad-recovery-service.test.ts,覆盖幂等 session 创建、Plus 拦截 与权益摘要、每日上限、会话过期、provider token 缺失、信任测试 provider、已完成会话幂等返回、rewardLedger 幂等命中 8 个场景。 Phase G4 全部完成。 --- docs/gamification-server-plan.md | 3 +- .../rewards/ad-recovery-service.test.ts | 301 ++++++++++++++++++ 2 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/services/rewards/ad-recovery-service.test.ts diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index db41e58..6598507 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -91,6 +91,7 @@ ## Phase G4:广告恢复与订阅权益对齐 验证记录(2026-05-13):G4-1 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint src/services/rewards/ad-recovery-service.ts`;广告恢复奖励现已通过 `rewardLedger` 统一结算层发放,使用 `ad_recovery:{sessionId}` 幂等 key,记录 stateBefore/After 快照。 +验证记录(2026-05-13):G4-7 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint`;测试覆盖幂等 session 创建、Plus 拦截+权益摘要、每日上限、会话过期、provider token 缺失、信任测试 provider、已完成会话幂等返回、rewardLedger 幂等 key 命中 8 个场景。 | # | 任务 | 状态 | 验收标准 | |---|------|------|----------| @@ -100,7 +101,7 @@ | G4-4 | 确认连续学习保护规则 | [x] | 每 7 天最多广告恢复 1 次,当天补一次保护 | | G4-5 | 收敛旧恢复接口用途 | [x] | 旧的直接恢复接口仅保留内部、测试或明确废弃,并在 API 文档中说明 | | G4-6 | 明确 Plus 分支 | [x] | Plus 用户无需看广告;同入口返回订阅权益或已订阅原因,不消耗广告次数 | -| G4-7 | 添加广告恢复回归测试 | [ ] | 覆盖 idempotency、过期、provider token 缺失、每日上限、Plus、重复 complete | +| G4-7 | 添加广告恢复回归测试 | [x] | 覆盖 idempotency、过期、provider token 缺失、每日上限、Plus、重复 complete | ## Phase G5:本周排行榜与周期结算 diff --git a/src/__tests__/services/rewards/ad-recovery-service.test.ts b/src/__tests__/services/rewards/ad-recovery-service.test.ts new file mode 100644 index 0000000..6bfea4c --- /dev/null +++ b/src/__tests__/services/rewards/ad-recovery-service.test.ts @@ -0,0 +1,301 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '../../../db/client.js'; +import { getProgressSummary } from '../../../services/learning/progress-summary-service.js'; +import { getSubscriptionStatus } from '../../../services/payment/subscription-service.js'; +import { + completeAdRecoverySession, + createAdRecoverySession, +} from '../../../services/rewards/ad-recovery-service.js'; +import type { ProgressSummaryDto } from '../../../types/app-api.js'; + +// ── Mock 外部服务 ────────────────────────────────────────────────── + +const mockProgress: ProgressSummaryDto = { + hearts: 2, + maxHearts: 5, + nextHeartRestoreAt: null, + dailyAttemptsLeft: 1, + dailyAttemptsMax: 3, + nextAttemptResetAt: null, + highRewardSessionsLeft: 1, + highRewardSessionsMax: 3, + xp: 100, + level: 3, + xpToNextLevel: 50, + streakDays: 5, + checkInDays: 5, + streakProtectedUntil: null, + activeTrackId: null, + isSubscribed: false, +}; + +const mockFullProgress: ProgressSummaryDto = { + ...mockProgress, + hearts: 5, + dailyAttemptsLeft: 3, +}; + +vi.mock('../../../services/learning/progress-summary-service.js', () => ({ + getProgressSummary: vi.fn(), + getDailyAttempts: vi.fn().mockResolvedValue({ left: 1, 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().from().where().limit().orderBy() 的链式调用,按调用顺序返回不同结果。 */ +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); +} + +/** 模拟 db.insert().values(),返回 values spy。 */ +function setupInsert() { + const valuesSpy = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.insert).mockReturnValue({ values: valuesSpy } as never); + return valuesSpy; +} + +/** 模拟 db.update().set().where(),返回 set spy。 */ +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('ad-recovery-service', () => { + beforeEach(() => { + vi.clearAllMocks(); + // 默认返回免费用户进度 + vi.mocked(getProgressSummary).mockResolvedValue(mockProgress); + }); + + // ── 创建 Session ──────────────────────────────────────────────── + + describe('createAdRecoverySession', () => { + const baseInput = { + type: 'hearts' as const, + clientRequestId: 'req-1', + platform: 'harmony' as const, + adProvider: 'mock', + }; + + it('为免费用户创建爱心恢复 session', async () => { + // 无已存在的重复请求、无今日恢复记录、免费用户 + setupSelectQueue([[], []]); + setupInsert(); + + const result = await createAdRecoverySession('user-1', baseInput); + + expect(result.eligible).toBe(true); + expect(result.sessionId).toBeTruthy(); + expect(result.type).toBe('hearts'); + expect(result.remainingToday).toBe(2); // 3 - 1 = 2 + }); + + it('Plus 用户被拦截并返回订阅权益摘要', async () => { + vi.mocked(getSubscriptionStatus) + .mockResolvedValue({ status: 'active', tier: 'pro', expiresAt: '2026-12-31', autoRenew: true }); + + // 无重复请求 + 免费用户 select(tier 查询) + setupSelectQueue([[], []]); + + const result = await createAdRecoverySession('user-1', baseInput); + + expect(result.eligible).toBe(false); + expect(result.reason).toBe('already_subscribed'); + expect(result.subscriptionBenefits).toBeDefined(); + expect(result.subscriptionBenefits!.unlimitedHearts).toBe(true); + expect(result.sessionId).toBeNull(); + }); + + it('每日上限耗尽时拒绝创建', async () => { + // 无重复请求,但今日已有 3 次恢复 + setupSelectQueue([[], [{ id: 's1' }, { id: 's2' }, { id: 's3' }]]); + + const result = await createAdRecoverySession('user-1', baseInput); + + expect(result.eligible).toBe(false); + expect(result.reason).toBe('daily_limit_reached'); + }); + + it('相同 clientRequestId 幂等返回已有 session', async () => { + const existingSession = { + id: 'session-existing', + type: 'hearts', + status: 'pending', + adPlacementId: 'duoqi_restore_hearts_harmony', + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + clientRequestId: 'req-1', + }; + setupSelectQueue([[existingSession]]); + setupUpdate(); // 用于 duplicateCount 自增 + + const result = await createAdRecoverySession('user-1', baseInput); + + expect(result.eligible).toBe(true); + expect(result.sessionId).toBe('session-existing'); + // 不应创建新的 session + expect(db.insert).not.toHaveBeenCalled(); + }); + }); + + // ── 完成 Session ──────────────────────────────────────────────── + + describe('completeAdRecoverySession', () => { + const now = new Date(); + const validSession = { + id: 'session-1', + userId: 'user-1', + type: 'hearts', + status: 'pending', + clientRequestId: 'req-1', + adProvider: 'mock', + expiresAt: new Date(now.getTime() + 30 * 60 * 1000), + }; + + const baseCompleteInput = { + sessionId: 'session-1', + clientRequestId: 'req-1', + adProvider: 'mock', + providerRewardToken: 'token-abc', + completedAt: now.toISOString(), + }; + + it('正常完成爱心恢复并写入 rewardLedger', async () => { + // getSession → validSession, getProgressSummary → mockProgress, + // checkEligibility 内部: getUserTier + getSubscriptionStatus + getProgressSummary + getLimits(completedCountToday) + setupSelectQueue([ + [validSession], // getSession + [], // rewardLedger 幂等检查(无已有记录) + [], // getUserTier(免费用户) + [], // completedCountToday hearts = 0 + ]); + setupUpdate(); // update users + update session + const insertSpy = setupInsert(); + + const result = await completeAdRecoverySession('user-1', baseCompleteInput); + + expect(result.status).toBe('completed'); + expect(result.type).toBe('hearts'); + expect(result.reward!.heartsDelta).toBeGreaterThan(0); + + // 验证 rewardLedger 写入了正确的 sourceType 和幂等 key + expect(insertSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourceType: 'ad_recovery', + sourceId: 'session-1', + idempotencyKey: 'ad_recovery:session-1', + status: 'completed', + }), + ); + }); + + it('会话不存在时返回失败', async () => { + setupSelectQueue([[]]); // getSession → 空 + + const result = await completeAdRecoverySession('user-1', baseCompleteInput); + + expect(result.status).toBe('failed'); + expect(result.reason).toBe('invalid_type'); + }); + + it('会话过期时标记为 expired', async () => { + const expiredSession = { + ...validSession, + expiresAt: new Date(now.getTime() - 60 * 1000), // 已过期 + }; + setupSelectQueue([[expiredSession]]); + setupUpdate(); + + const result = await completeAdRecoverySession('user-1', baseCompleteInput); + + expect(result.status).toBe('failed'); + expect(result.reason).toBe('session_expired'); + }); + + it('缺少 providerRewardToken 时(非信任 provider)拒绝完成', async () => { + setupSelectQueue([[validSession]]); + setupUpdate(); + + const result = await completeAdRecoverySession('user-1', { + ...baseCompleteInput, + adProvider: 'unknown_provider', + providerRewardToken: undefined, + }); + + expect(result.status).toBe('failed'); + expect(result.reason).toBe('provider_verification_failed'); + }); + + it('信任的测试 provider 无需 token 即可通过', async () => { + // mock provider 是 'mock',属于 TRUSTED_TEST_PROVIDERS + setupSelectQueue([ + [validSession], + [], // rewardLedger + [], // getUserTier + [], // completedCountToday + ]); + setupUpdate(); + setupInsert(); + + const result = await completeAdRecoverySession('user-1', { + ...baseCompleteInput, + providerRewardToken: undefined, + // adProvider 已经是 'mock' + }); + + expect(result.status).toBe('completed'); + }); + + it('已完成的会话幂等返回之前的结果', async () => { + const completedSession = { + ...validSession, + status: 'completed', + rewardSnapshot: { heartsDelta: 3, dailyAttemptsDelta: 0, streakProtectionGranted: false }, + progressAfter: mockFullProgress, + }; + setupSelectQueue([[completedSession]]); + setupUpdate(); // duplicateCount 自增 + + const result = await completeAdRecoverySession('user-1', baseCompleteInput); + + expect(result.status).toBe('completed'); + expect(result.reward!.heartsDelta).toBe(3); + // 不应写入新的 rewardLedger + expect(db.insert).not.toHaveBeenCalled(); + }); + + it('rewardLedger 幂等 key 命中时不重复写入流水', async () => { + setupSelectQueue([ + [validSession], + [{ id: 'ledger-existing' }], // rewardLedger 已有记录 + ]); + setupUpdate(); + + const result = await completeAdRecoverySession('user-1', baseCompleteInput); + + // 幂等返回成功,但不重新写入流水 + expect(result.status).toBe('completed'); + expect(db.insert).not.toHaveBeenCalled(); + }); + }); +});