添加广告恢复回归测试覆盖 G4-7
新增 ad-recovery-service.test.ts,覆盖幂等 session 创建、Plus 拦截 与权益摘要、每日上限、会话过期、provider token 缺失、信任测试 provider、已完成会话幂等返回、rewardLedger 幂等命中 8 个场景。 Phase G4 全部完成。
This commit is contained in:
parent
de0055e794
commit
eee2116633
@ -91,6 +91,7 @@
|
|||||||
## Phase G4:广告恢复与订阅权益对齐
|
## 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-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-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:本周排行榜与周期结算
|
||||||
|
|
||||||
|
|||||||
301
src/__tests__/services/rewards/ad-recovery-service.test.ts
Normal file
301
src/__tests__/services/rewards/ad-recovery-service.test.ts
Normal 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 });
|
||||||
|
|
||||||
|
// 无重复请求 + 免费用户 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user