添加广告恢复回归测试覆盖 G4-7

新增 ad-recovery-service.test.ts,覆盖幂等 session 创建、Plus 拦截
与权益摘要、每日上限、会话过期、provider token 缺失、信任测试
provider、已完成会话幂等返回、rewardLedger 幂等命中 8 个场景。
Phase G4 全部完成。
This commit is contained in:
Wang Zhuoxuan 2026-05-13 20:34:55 +08:00
parent de0055e794
commit eee2116633
2 changed files with 303 additions and 1 deletions

View File

@ -91,6 +91,7 @@
## Phase G4广告恢复与订阅权益对齐 ## Phase G4广告恢复与订阅权益对齐
验证记录2026-05-13G4-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-13G4-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-13G4-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-4 | 确认连续学习保护规则 | [x] | 每 7 天最多广告恢复 1 次,当天补一次保护 |
| G4-5 | 收敛旧恢复接口用途 | [x] | 旧的直接恢复接口仅保留内部、测试或明确废弃,并在 API 文档中说明 | | G4-5 | 收敛旧恢复接口用途 | [x] | 旧的直接恢复接口仅保留内部、测试或明确废弃,并在 API 文档中说明 |
| G4-6 | 明确 Plus 分支 | [x] | Plus 用户无需看广告;同入口返回订阅权益或已订阅原因,不消耗广告次数 | | G4-6 | 明确 Plus 分支 | [x] | Plus 用户无需看广告;同入口返回订阅权益或已订阅原因,不消耗广告次数 |
| G4-7 | 添加广告恢复回归测试 | [ ] | 覆盖 idempotency、过期、provider token 缺失、每日上限、Plus、重复 complete | | G4-7 | 添加广告恢复回归测试 | [x] | 覆盖 idempotency、过期、provider token 缺失、每日上限、Plus、重复 complete |
## Phase G5本周排行榜与周期结算 ## Phase G5本周排行榜与周期结算

View File

@ -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 });
// 无重复请求 + 免费用户 selecttier 查询)
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();
});
});
});