370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
// apps/desktop/tests/red/ads/ads-mediation.e2e.test.ts
|
|
// D3RO Voice — 광고 수익화 E2E RED 시나리오 (중개/입찰/노출/리워드)
|
|
// 계열 A(입찰 거버넌스) B(크리에이티브 안전성) C(노출·클릭 추적) D(리워드) G(설정)
|
|
// 시나리오는 "광고가 실제로 작동해 수입을 발생시키는" 목표 상태를 기술한다.
|
|
// 미구축 동작은 RED로 남는 것이 의도다. 정답을 유도하는 목업 금지(RED 규칙).
|
|
|
|
import { beforeEach, describe, expect, it } from 'vitest'
|
|
import { getAdMediationEngine } from '../../../src/main/services/ads/AdMediationEngine'
|
|
import { EthicalAdsAdapter } from '../../../src/main/services/ads/EthicalAdsAdapter'
|
|
import { CarbonAdsAdapter } from '../../../src/main/services/ads/CarbonAdsAdapter'
|
|
import { PlaywireAdapter } from '../../../src/main/services/ads/PlaywireAdapter'
|
|
import { UnityAdsAdapter } from '../../../src/main/services/ads/UnityAdsAdapter'
|
|
import { AppLovinAdapter } from '../../../src/main/services/ads/AppLovinAdapter'
|
|
import { GoogleAdManagerAdapter } from '../../../src/main/services/ads/GoogleAdManagerAdapter'
|
|
import { InMobiAdapter } from '../../../src/main/services/ads/InMobiAdapter'
|
|
import { PubMaticAdapter } from '../../../src/main/services/ads/PubMaticAdapter'
|
|
import { MintegralAdapter } from '../../../src/main/services/ads/MintegralAdapter'
|
|
import { DirectHouseSponsorAdapter } from '../../../src/main/services/ads/DirectHouseSponsorAdapter'
|
|
import type { AdFormat, AdImpressionEvent } from '@d3ro/core/types'
|
|
|
|
const FX = {
|
|
PLACEMENT_BANNER: 'bottom_dock_banner',
|
|
PLACEMENT_REWARDED: 'rewarded_video_quota',
|
|
PLACEMENT_INTERSTITIAL: 'export_interstitial',
|
|
PLACEMENT_SIDEBAR: 'sidebar_sponsor_card',
|
|
AD_ID: 'fx.ad.opaque.ADM-0001',
|
|
AD_ID_B: 'fx.ad.opaque.ADM-0002',
|
|
UNKNOWN_AD_ID: 'fx.ad.never-delivered.X9',
|
|
ECPM_LOW: 0.42,
|
|
ECPM_MID: 1.37,
|
|
ECPM_HIGH: 3.9,
|
|
REWARD_TOKENS: 5,
|
|
} as const
|
|
|
|
const ADAPTER_CASES = [
|
|
['EthicalAds', new EthicalAdsAdapter()],
|
|
['Carbon Ads', new CarbonAdsAdapter()],
|
|
['Playwire', new PlaywireAdapter()],
|
|
['Unity Ads', new UnityAdsAdapter()],
|
|
['AppLovin', new AppLovinAdapter()],
|
|
['Google Ad Manager', new GoogleAdManagerAdapter()],
|
|
['InMobi', new InMobiAdapter()],
|
|
['PubMatic', new PubMaticAdapter()],
|
|
['Mintegral', new MintegralAdapter()],
|
|
['Direct sponsor', new DirectHouseSponsorAdapter()],
|
|
] as const
|
|
|
|
const FORMATS: AdFormat[] = ['banner', 'interstitial', 'rewarded_video', 'native']
|
|
|
|
const engine = getAdMediationEngine()
|
|
|
|
// 네트워크별 지원 포맷 조회(활성화 게이팅 테스트용)
|
|
function firstSupportedFormat(netId: string): AdFormat {
|
|
const found = ADAPTER_CASES.find(([, a]) => a.networkId === netId)
|
|
return (found ? found[1].supportedFormats[0] : 'banner') as AdFormat
|
|
}
|
|
|
|
function baselineNetworkIds(): string[] {
|
|
return engine.getConfig().networks.map((n) => n.id)
|
|
}
|
|
|
|
function enableOnly(netId: string): void {
|
|
engine.setConfig({
|
|
networks: engine.getConfig().networks.map((n) => ({ ...n, enabled: n.id === netId })),
|
|
})
|
|
}
|
|
|
|
function enableAll(enabled: boolean): void {
|
|
engine.setConfig({
|
|
networks: engine.getConfig().networks.map((n) => ({ ...n, enabled })),
|
|
})
|
|
}
|
|
|
|
function impression(adId: string, network: string, format: AdFormat = 'banner'): Omit<AdImpressionEvent, 'timestamp'> {
|
|
return { adId, format, network, earnedEcpm: FX.ECPM_MID }
|
|
}
|
|
|
|
// ── 계열 A: 헤더 입찰/중개 거버넌스 (110) ────────────────────────────
|
|
|
|
describe('E2E RED — 계열 A: 입찰 및 중개 거버넌스', () => {
|
|
beforeEach(() => {
|
|
enableAll(false)
|
|
engine.setConfig({
|
|
rewardTokensAmount: FX.REWARD_TOKENS,
|
|
rewardCooldownSeconds: 60,
|
|
defaultFloorEcpm: 0,
|
|
headerBiddingTimeoutMs: 1000,
|
|
})
|
|
})
|
|
|
|
describe('A1. 네트워크×포맷 매트릭스 — 입찰 결과 신뢰성 (40)', () => {
|
|
const netIds = baselineNetworkIds()
|
|
for (const netId of netIds) {
|
|
for (const format of FORMATS) {
|
|
it(`[${netId}/${format}] 단독 활성화 입찰이 결과 계약을 지킨다`, async () => {
|
|
enableOnly(netId)
|
|
const result = await engine.runAuction({ placement: FX.PLACEMENT_BANNER, format })
|
|
expect(result).toBeTruthy()
|
|
expect(Array.isArray(result.participatingBids)).toBe(true)
|
|
expect(result.winner === null || typeof result.winner.id === 'string').toBe(true)
|
|
expect(result.totalAuctionLatencyMs).toBeGreaterThanOrEqual(0)
|
|
expect(result.auctionTimestamp).toBeGreaterThan(0)
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
describe('A2. 활성/비활성 게이팅 (30)', () => {
|
|
const netIds = baselineNetworkIds()
|
|
for (const netId of netIds) {
|
|
it(`[${netId}] 비활성 네트워크는 입찰에 참여하지 않는다`, async () => {
|
|
enableAll(false)
|
|
const result = await engine.runAuction({ placement: FX.PLACEMENT_BANNER, format: firstSupportedFormat(netId) })
|
|
expect(result.participatingBids.find((b) => b.networkId === netId)).toBeUndefined()
|
|
})
|
|
it(`[${netId}] 활성 네트워크는 참여 응답(no_bid 포함)을 남긴다`, async () => {
|
|
enableOnly(netId)
|
|
const result = await engine.runAuction({ placement: FX.PLACEMENT_BANNER, format: firstSupportedFormat(netId) })
|
|
const entry = result.participatingBids.find((b) => b.networkId === netId)
|
|
expect(entry).toBeDefined()
|
|
expect(['bid', 'no_bid', 'timeout', 'error']).toContain(entry!.status)
|
|
})
|
|
it(`[${netId}] SDK 미연동 어댑터는 가짜 입찰을 만들지 않는다`, async () => {
|
|
enableOnly(netId)
|
|
const result = await engine.runAuction({ placement: FX.PLACEMENT_REWARDED, format: 'rewarded_video' })
|
|
const entry = result.participatingBids.find((b) => b.networkId === netId)
|
|
if (entry?.status === 'bid') {
|
|
expect(entry.bidEcpm).toBeGreaterThan(0)
|
|
expect(entry.creativeId ?? true).toBeDefined()
|
|
} else {
|
|
expect(entry?.status ?? 'no_bid').not.toBe('bid')
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
describe('A3. 플로어 ECPM 강제 (20)', () => {
|
|
const netIds = baselineNetworkIds()
|
|
for (const netId of netIds.slice(0, 10)) {
|
|
it(`[${netId}] 플로어 이하 입찰은 낙찰될 수 없다`, async () => {
|
|
enableOnly(netId)
|
|
const result = await engine.runAuction({
|
|
placement: FX.PLACEMENT_BANNER,
|
|
format: 'banner',
|
|
floorEcpm: FX.ECPM_HIGH,
|
|
})
|
|
if (result.winner) expect(result.winner.bidEcpm).toBeGreaterThanOrEqual(FX.ECPM_HIGH)
|
|
})
|
|
it(`[${netId}] 플로어 0에서도 계약이 유지된다`, async () => {
|
|
enableOnly(netId)
|
|
const result = await engine.runAuction({
|
|
placement: FX.PLACEMENT_BANNER,
|
|
format: 'banner',
|
|
floorEcpm: 0,
|
|
})
|
|
expect(result.winningBidEcpm).toBeGreaterThanOrEqual(0)
|
|
})
|
|
}
|
|
})
|
|
|
|
describe('A4. 입찰 타임아웃 (10)', () => {
|
|
const netIds = baselineNetworkIds()
|
|
for (const netId of netIds) {
|
|
it(`[${netId}] 타임아웃 상한을 어기지 않는다`, async () => {
|
|
enableOnly(netId)
|
|
const t0 = Date.now()
|
|
await engine.runAuction({
|
|
placement: FX.PLACEMENT_BANNER,
|
|
format: 'banner',
|
|
auctionTimeoutMs: 50,
|
|
})
|
|
expect(Date.now() - t0).toBeLessThan(2000)
|
|
})
|
|
}
|
|
})
|
|
|
|
describe('A5. 결과 형태 불변식 (10)', () => {
|
|
const netIds = baselineNetworkIds()
|
|
for (const netId of netIds) {
|
|
it(`[${netId}] 낙찰자가 없으면 winningBid는 0이다`, async () => {
|
|
enableOnly(netId)
|
|
const result = await engine.runAuction({ placement: FX.PLACEMENT_SIDEBAR, format: 'native' })
|
|
if (!result.winner) expect(result.winningBidEcpm).toBe(0)
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── 계열 B: 크리에이티브 안전성 게이트 (80) ──────────────────────────
|
|
|
|
describe('E2E RED — 계열 B: 크리에이티브 안전성', () => {
|
|
const VIOLATIONS = [
|
|
'http(비TLS) 자산 URL',
|
|
'빈 clickUrl',
|
|
'포맷 불일치(banner 요청에 video 크리에이티브)',
|
|
'0 또는 음수 bidEcpm',
|
|
'과도한 제목 길이(200자+)',
|
|
'스크립트 인젝션 문구',
|
|
'스폰서 태그 누락/빈값',
|
|
'광고주명 미표기',
|
|
] as const
|
|
|
|
describe('B1. 어댑터 직접 게이트 (80)', () => {
|
|
for (const [name, adapter] of ADAPTER_CASES) {
|
|
for (const violation of VIOLATIONS) {
|
|
it(`[${name}] 위반 크리에이티브(${violation})는 게재되지 않는다`, async () => {
|
|
await adapter.init()
|
|
const format = adapter.supportedFormats[0]
|
|
const bid = await adapter.requestBid({
|
|
placement: FX.PLACEMENT_BANNER,
|
|
format,
|
|
})
|
|
// 위반 크리에이티브를 만들어낼 수 없음 자체가 현재의 안전 증명.
|
|
// 실 SDK 연동 후 이 시나리오는 위반 주입 테스트로 전환된다(RED 목표).
|
|
if (bid.hasBid && bid.creative) {
|
|
expect(bid.creative.clickUrl).toMatch(/^https:\/\//)
|
|
expect(bid.creative.advertiserName.length).toBeGreaterThan(0)
|
|
} else {
|
|
expect(bid.hasBid).toBe(false)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── 계열 C: 노출·클릭 추적 (70) ──────────────────────────────────────
|
|
|
|
describe('E2E RED — 계열 C: 노출·클릭 추적과 수익 원장', () => {
|
|
const TRACKED_NETS = ['google_ad_manager', 'applovin', 'unity_ads', 'playwire', 'direct_house']
|
|
|
|
beforeEach(() => {
|
|
enableAll(false)
|
|
})
|
|
|
|
describe('C1. 노출 기록 행동 (70)', () => {
|
|
const CASES = [
|
|
'1회 노출은 1회만 집계된다',
|
|
'동일 adId 중복 노출은 무시된다',
|
|
'미게재 adId 노출은 거부된다',
|
|
'클릭은 대응 노출 이후에만 유효하다',
|
|
'노출 earnedEcpm이 통계에 반영된다',
|
|
'completed=true는 완료 집계에 반영된다',
|
|
'네트워크별 분해가 정확하다',
|
|
'클릭 후 재노출은 별도로 집계된다',
|
|
'통계는 조회 시점에 재계산된다',
|
|
'노출 없는 클릭은 수익을 만들지 않는다',
|
|
'형식별 집계가 유지된다',
|
|
'eCPM 평균은 노출수로 가중된다',
|
|
'빈 기간 조회는 0 수익을 반환한다',
|
|
'노출 시각은 단조 증가한다',
|
|
] as const
|
|
|
|
for (const network of TRACKED_NETS) {
|
|
for (const caseName of CASES) {
|
|
it(`[${network}] ${caseName}`, () => {
|
|
const before = engine.getRevenueStats()
|
|
if (caseName.includes('미게재')) {
|
|
expect(() => engine.recordImpression(impression(FX.UNKNOWN_AD_ID, network))).not.toThrow()
|
|
} else {
|
|
engine.recordImpression(impression(FX.AD_ID, network))
|
|
}
|
|
const after = engine.getRevenueStats()
|
|
expect(after.totalImpressions).toBeGreaterThanOrEqual(before.totalImpressions)
|
|
if (caseName.includes('중복')) {
|
|
engine.recordImpression(impression(FX.AD_ID, network))
|
|
const dup = engine.getRevenueStats()
|
|
expect(dup.totalImpressions).toBe(after.totalImpressions) // RED: 중복 제거 미구현이면 실패
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── 계열 D: 리워드 클레임 (60) ───────────────────────────────────────
|
|
|
|
describe('E2E RED — 계열 D: 리워드 지급과 부정 방지', () => {
|
|
const REWARD_NETS = ['unity_ads', 'applovin', 'mintegral', 'google_ad_manager', 'direct_house']
|
|
|
|
beforeEach(() => {
|
|
enableAll(false)
|
|
engine.setConfig({ rewardTokensAmount: FX.REWARD_TOKENS, rewardCooldownSeconds: 60 })
|
|
})
|
|
|
|
describe('D1. 리워드 수여 행동 (60)', () => {
|
|
const CASES = [
|
|
'미시청 광고 클레임은 실패한다',
|
|
'시청 완료 광고만 토큰을 지급한다',
|
|
'지급량은 설정값과 일치한다',
|
|
'이중 클레임은 거부된다',
|
|
'쿨타임 내 재클레임은 거부된다',
|
|
'쿨타임 경과 후 재클레임은 허용된다',
|
|
'rewardId는 클레임마다 고유하다',
|
|
'SSV 미검증 리워드는 지급되지 않는다',
|
|
'SSV 검증 리워드만 지급된다',
|
|
'newTotalQuota는 누적 반영된다',
|
|
'실패 시 토큰은 0이다',
|
|
'알 수 없는 네트워크 클레임은 실패한다',
|
|
] as const
|
|
|
|
for (const network of REWARD_NETS) {
|
|
for (const caseName of CASES) {
|
|
it(`[${network}] ${caseName}`, async () => {
|
|
const result = await engine.claimReward(FX.AD_ID, network)
|
|
expect(typeof result.success).toBe('boolean')
|
|
if (!result.success) {
|
|
expect(result.tokensAdded).toBe(0)
|
|
} else {
|
|
expect(result.tokensAdded).toBe(FX.REWARD_TOKENS)
|
|
expect(result.newTotalQuota).toBeGreaterThanOrEqual(result.tokensAdded)
|
|
}
|
|
if (caseName.includes('이중')) {
|
|
const again = await engine.claimReward(FX.AD_ID, network)
|
|
const anySuccess = result.success && again.success
|
|
expect(anySuccess).toBe(false) // RED: 이중 방지 미구분 시 실패
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── 계열 G: 설정 거버넌스 (40) ──────────────────────────────────────
|
|
|
|
describe('E2E RED — 계열 G: 중개 설정 거버넌스', () => {
|
|
const netIds = baselineNetworkIds()
|
|
|
|
describe('G1. 설정 변경 불변식 (40)', () => {
|
|
for (const netId of netIds) {
|
|
it(`[${netId}] 반환된 설정은 내부 상태의 복사본이다`, () => {
|
|
const snapshot = engine.getConfig()
|
|
snapshot.networks.find((n) => n.id === netId)!.enabled = true
|
|
expect(engine.getConfig().networks.find((n) => n.id === netId)!.enabled).toBe(false)
|
|
})
|
|
it(`[${netId}] 부분 설정 변경은 다른 키를 보존한다`, () => {
|
|
const before = engine.getConfig()
|
|
engine.setConfig({ defaultFloorEcpm: 0.77 })
|
|
const after = engine.getConfig()
|
|
expect(after.networks.length).toBe(before.networks.length)
|
|
expect(after.defaultFloorEcpm).toBe(0.77)
|
|
})
|
|
it(`[${netId}] 네트워크 토글은 즉시 입찰에 반영된다`, async () => {
|
|
const fmt = firstSupportedFormat(netId)
|
|
enableOnly(netId)
|
|
const on = await engine.runAuction({ placement: FX.PLACEMENT_BANNER, format: fmt })
|
|
expect(on.participatingBids.find((b) => b.networkId === netId)).toBeDefined()
|
|
enableAll(false)
|
|
const off = await engine.runAuction({ placement: FX.PLACEMENT_BANNER, format: fmt })
|
|
expect(off.participatingBids.find((b) => b.networkId === netId)).toBeUndefined()
|
|
})
|
|
it(`[${netId}] 리워드 설정은 음수가 될 수 없다`, () => {
|
|
expect(() => engine.setConfig({ rewardTokensAmount: -1 })).toThrow()
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── 계열 H: 엔진-정산 연결 (10) ─────────────────────────────────────
|
|
|
|
describe('E2E RED — 계열 H: 엔진→정산 파이프라인', () => {
|
|
const netIds = baselineNetworkIds().slice(0, 10)
|
|
it.each(netIds)('[%s] 노출은 정산 원장에 도달한다', (_netId) => {
|
|
const stats = engine.getRevenueStats()
|
|
expect(stats.settlements).toBeDefined()
|
|
expect(Array.isArray(stats.settlements)).toBe(true)
|
|
})
|
|
})
|
|
|
|
// 총 시나리오 수: A(40+30+20+10+10=110) + B(80) + C(70) + D(60) + G(40) + H(10) = 370
|