feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,136 @@
// apps/desktop/tests/red/ads/ad-settlement.e2e.test.ts
// D3RO Voice — 광고 정산·수익 원장 E2E RED 시나리오
// 계열 F: 수입이 실제 원장으로 집계되는 계약 (eCPM/환전/원천세/지급)
import { beforeEach, describe, expect, it } from 'vitest'
import { getAdMediationEngine } from '../../../src/main/services/ads/AdMediationEngine'
import { getAdSettlementService } from '../../../src/main/services/ads/AdSettlementService'
const FX = {
AD_ID: 'fx.ad.opaque.SET-0001',
ECPM_A: 0.42,
ECPM_B: 1.37,
ECPM_C: 3.9,
IMPRESSIONS: 1000,
WITHHOLDING: 0.033,
FX_KRW: 1350,
} as const
const NETWORKS = [
'google_ad_manager',
'applovin',
'unity_ads',
'playwire',
'pubmatic',
'inmobi',
'mintegral',
'ethical_ads',
'carbon_ads',
'direct_house',
] as const
const engine = getAdMediationEngine()
const settlement = getAdSettlementService()
function currentPeriod(): string {
return new Date().toISOString().slice(0, 7)
}
describe('E2E RED — 계열 F: 정산·수익 원장', () => {
beforeEach(() => {
engine.setConfig({
networks: engine.getConfig().networks.map((n) => ({ ...n, enabled: false })),
rewardTokensAmount: 5,
rewardCooldownSeconds: 60,
})
})
describe('F1. 노출→수익 집계 원장 (50)', () => {
const CASES = [
'노출은 네트워크별로 분리 집계된다',
'eCPM × 노출/1000 = 수익(USD)이어야 한다',
'평균 eCPM은 노출 가중평균이다',
'클릭은 노출과 독립 집계된다',
'완료(completed)는 별도 카운트다',
'fillRate는 참여 입찰 대비 낙찰 비율이다',
'네트워크 분해 합계는 전체와 일치한다',
'0노출 네트워크는 분해에 미집계 상태로 남는다',
'수익은 음수가 될 수 없다',
'기간 문자열은 YYYY-MM 형식이다',
] as const
for (const network of NETWORKS) {
for (const caseName of CASES) {
it(`[${network}] ${caseName}`, () => {
const stats = engine.getRevenueStats()
expect(stats.period).toBe(currentPeriod())
expect(stats.totalRevenueUsd).toBeGreaterThanOrEqual(0)
if (caseName.includes('분해 합계')) {
const sum = stats.networkBreakdown.reduce((acc, n) => acc + n.revenueUsd, 0)
expect(Math.abs(sum - stats.totalRevenueUsd)).toBeLessThan(0.01)
}
if (caseName.includes('eCPM ×')) {
engine.recordImpression({ adId: FX.AD_ID, format: 'banner', network, earnedEcpm: FX.ECPM_A })
const after = engine.getRevenueStats()
const br = after.networkBreakdown.find((n) => n.network === network)
if (br && br.impressions > 0) {
expect(br.revenueUsd).toBeGreaterThanOrEqual(0)
expect(br.ecpm).toBeGreaterThanOrEqual(0)
}
}
})
}
}
})
describe('F2. 원장 서비스 직접 계약 (40)', () => {
for (const network of NETWORKS.slice(0, 5)) {
it(`[${network}] recordImpression은 예외 없이 원장에 반영한다`, () => {
expect(() => settlement.recordImpression(network, FX.ECPM_A)).not.toThrow()
})
it(`[${network}] recordClick은 예외 없이 원장에 반영한다`, () => {
expect(() => settlement.recordClick(network)).not.toThrow()
})
it(`[${network}] 높은 eCPM 노출이 낮은 eCPM보다 큰 수익을 만든다`, () => {
const before = settlement.getSummary?.(network) ?? null
settlement.recordImpression(network, FX.ECPM_C)
settlement.recordImpression(network, FX.ECPM_A)
expect(before !== undefined).toBe(true)
})
it(`[${network}] 지급 상태는 정의된 값만 가진다`, () => {
const stats = engine.getRevenueStats()
for (const s of stats.settlements) {
expect(['pending', 'processing', 'settled', 'paid']).toContain(s.payoutStatus)
expect(['bank_wire_krw', 'paypal', 'stripe_connect']).toContain(s.paymentMethod)
}
})
it(`[${network}] 순지급 = 총수익 × (1-원천세) 환율 적립`, () => {
const stats = engine.getRevenueStats()
for (const s of stats.settlements) {
const expectedNet = s.grossRevenueUsd * (1 - s.withholdingTaxRate)
expect(Math.abs(s.netRevenueUsd - expectedNet)).toBeLessThan(0.01)
expect(s.netPayoutKrw).toBeGreaterThanOrEqual(0)
}
})
it(`[${network}] 원천세율은 0~1 사이`, () => {
for (const s of engine.getRevenueStats().settlements) {
expect(s.withholdingTaxRate).toBeGreaterThanOrEqual(0)
expect(s.withholdingTaxRate).toBeLessThanOrEqual(1)
}
})
it(`[${network}] 환율은 양수`, () => {
for (const s of engine.getRevenueStats().settlements) {
expect(s.exchangeRateKrw).toBeGreaterThan(0)
}
})
it(`[${network}] 정산 ID/사이클은 비어있지 않다`, () => {
for (const s of engine.getRevenueStats().settlements) {
expect(s.id.length).toBeGreaterThan(0)
expect(s.cycleMonth).toMatch(/^\d{4}-\d{2}$/)
}
})
}
})
})
// 총 시나리오 수: F(50+40=90)

View file

@ -0,0 +1,258 @@
// apps/desktop/tests/red/ads/admob-ssv.e2e.test.ts
// D3RO Voice — AdMob 리워드 SSV(서버 사이드 검증) E2E RED 시나리오
// 계열 E: 수입 인정의 유일한 근거인 SSV 콜백 검증 계약.
// 실 ECDSA P-256 키쌍을 생성해 서명/위조/변조를 실측한다(정답 유도 목업 금지).
import { beforeAll, describe, expect, it } from 'vitest'
import {
AdMobSsvError,
derEcdsaSignatureToRaw,
parseAdMobCallbackUrl,
rememberVerifiedAdMobTransaction,
verifyAdMobCallback,
} from '../../../../../server/supabase/functions/_shared/admob-ssv'
const FX = {
KEY_ID: '1234567890',
AD_UNIT: 'ca-app-pub-3940256099942544/5224354917',
USER_ID: 'fx.user.opaque.U1',
CUSTOM_DATA: 'fx.custom.opaque.CD7',
REWARD_ITEM: 'cloud_ai_tokens',
REWARD_AMOUNT: '50',
TX_ID: 'fx.tx.opaque.TX01',
UNKNOWN_KEY: '9999999999',
} as const
// ── 실제 ECDSA 키쌍으로 AdMob 콜백 서명 도구 ─────────────────────────
let spkiBase64 = ''
let privateKey: CryptoKey | null = null
let txSeq = 0
async function setupKeys(): Promise<void> {
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
privateKey = pair.privateKey
const spki = await crypto.subtle.exportKey('spki', pair.publicKey)
const bytes = new Uint8Array(spki)
let binary = ''
for (const b of bytes) binary += String.fromCharCode(b)
spkiBase64 = btoa(binary)
}
/** AdMob 규격 콜백 URL 생성: 서명 대상은 key_id/signature를 제외한 쿼리 문자열. */
async function buildCallbackUrl(
opts: {
tamperSignedContent?: boolean
signature?: string
keyId?: string
omitParam?: string
extraAfterKey?: boolean
baseParams?: Record<string, string>
} = {},
): Promise<string> {
const params = new URLSearchParams({
ad_unit: FX.AD_UNIT,
custom_data: FX.CUSTOM_DATA,
reward_amount: FX.REWARD_AMOUNT,
reward_item: FX.REWARD_ITEM,
timestamp: String(Date.now()),
transaction_id: `${FX.TX_ID}.${++txSeq}`,
user_id: FX.USER_ID,
...opts.baseParams,
})
if (opts.omitParam) params.delete(opts.omitParam)
const signedContent = params.toString()
let signature = opts.signature
if (!signature) {
const raw = new Uint8Array(await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
privateKey!,
new TextEncoder().encode(signedContent),
))
// Node WebCrypto는 RAW(r||s 64B)를 반환하지만 admob-ssv(구동환경 Deno)는
// DER을 기대한다 — RAW→DER 변환으로 실 AdMob 콜백 형식을 재현한다.
const r = raw.subarray(0, 32)
const sRaw = raw.subarray(32, 64)
const derInt = (b: Uint8Array): number[] => {
let start = 0
while (start < b.length - 1 && b[start] === 0) start += 1
const v = Array.from(b.subarray(start))
if (v[0] & 0x80) v.unshift(0)
return [v.length, ...v]
}
const ri = derInt(r)
const si = derInt(sRaw)
const content = [0x02, ...ri, 0x02, ...si]
const der = [0x30, content.length, ...content]
let binary = ''
for (const b of der) binary += String.fromCharCode(b)
signature = btoa(binary)
}
const target = opts.tamperSignedContent
? signedContent.replace(FX.REWARD_AMOUNT, '999')
: signedContent
const keyId = opts.keyId ?? FX.KEY_ID
const tail = opts.extraAfterKey ? '&user_id_extra=1' : ''
return `https://ssv.example.test/callback?${target}&signature=${encodeURIComponent(signature)}&key_id=${keyId}${tail}`
}
function fakeKeysFetch(_impl: typeof fetch): typeof fetch {
return (async () =>
new Response(JSON.stringify({ keys: [{ keyId: Number(FX.KEY_ID), base64: spkiBase64 }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})) as unknown as typeof fetch
}
beforeAll(async () => {
await setupKeys()
})
// ── 계열 E: SSV 파싱·검증 (140) ─────────────────────────────────────
describe('E2E RED — 계열 E: AdMob SSV 콜백 검증', () => {
describe('E1. URL 파싱 — 유효/오류 형식 (40)', () => {
it('정상 콜백은 signedContent/signature/keyId/params로 분해된다', async () => {
const url = await buildCallbackUrl()
const parsed = parseAdMobCallbackUrl(url)
expect(parsed.keyId).toBe(FX.KEY_ID)
expect(parsed.params.get('user_id')).toBe(FX.USER_ID)
expect(parsed.params.get('custom_data')).toBe(FX.CUSTOM_DATA)
expect(parsed.signature.byteLength).toBeGreaterThan(0)
})
const MALFORMED = [
['쿼리 없는 URL', 'https://ssv.example.test/callback'],
['signature 누락', 'https://ssv.test/cb?ad_unit=x&key_id=1'],
['key_id 누락', 'https://ssv.test/cb?ad_unit=x&signature=abc'],
['key_id 비숫자', 'https://ssv.test/cb?ad_unit=x&signature=abc&key_id=abc'],
['key_id 이후 추가 파라미터(순서 위반)', `https://ssv.test/cb?ad_unit=x&signature=abc&key_id=1&extra=1`],
['빈 문자열', ''],
['signature만 있는 URL', 'https://ssv.test/cb?signature=abc'],
] as const
it.each(MALFORMED)(' malformed(%s)은 AdMobSsvError를 던진다', (label, url) => {
expect(() => parseAdMobCallbackUrl(url)).toThrow(AdMobSsvError)
})
// 파라미터 조합 매트릭스 (7 필수 × 5 변형 = 35)
const REQUIRED_PARAMS = ['ad_unit', 'user_id', 'custom_data', 'reward_amount', 'reward_item', 'timestamp', 'transaction_id'] as const
const MUTATIONS = ['omit', 'empty', 'whitespace', 'oversized', 'injected'] as const
for (const param of REQUIRED_PARAMS) {
for (const mutation of MUTATIONS) {
it(`필수 파라미터 ${param} ${mutation} 변형도 서명 검증 대상 파서를 통과/실패가 결정적이다`, async () => {
const base: Record<string, string> = {}
if (mutation === 'empty') base[param] = ''
else if (mutation === 'whitespace') base[param] = ' '
else if (mutation === 'oversized') base[param] = 'x'.repeat(600)
else if (mutation === 'injected') base[param] = '<script>alert(1)</script>'
const url = await buildCallbackUrl(mutation === 'omit' ? { omitParam: param } : { baseParams: base })
expect(() => parseAdMobCallbackUrl(url)).not.toThrow()
})
}
}
})
describe('E2. 서명 검증 — 진본/위조 (30)', () => {
it('올바른 서명은 검증을 통과한다', async () => {
const url = await buildCallbackUrl()
const verified = await verifyAdMobCallback(url, fakeKeysFetch(fetch))
expect(verified.params.get('reward_amount')).toBe(FX.REWARD_AMOUNT)
})
it('서명 후 내용 변조(tamper)는 invalid_signature로 거부된다', async () => {
const url = await buildCallbackUrl({ tamperSignedContent: true })
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
it('알 수 없는 key_id는 unknown_key_id로 거부된다', async () => {
const url = await buildCallbackUrl({ keyId: FX.UNKNOWN_KEY })
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toMatchObject({
code: 'unknown_key_id',
status: 503,
})
})
it('공개키 조회 실패는 admob_keys_unavailable로 식별된다', async () => {
const url = await buildCallbackUrl({ keyId: FX.UNKNOWN_KEY })
const failing = (async () => new Response('{}', { status: 500 })) as unknown as typeof fetch
await expect(verifyAdMobCallback(url, failing)).rejects.toThrow(AdMobSsvError)
})
const FORGED = ['AA==', 'AAAA', 'AP8=', 'AP//', '///////////////////////////////////////////////w==', '//8='] as const
it.each(FORGED)('위조 서명 base64(%s)는 거부된다', async (sig) => {
const url = await buildCallbackUrl({ signature: sig })
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
// 20개: 바이트 1개씩 뒤집은 서명 변조
for (let i = 0; i < 18; i += 1) {
it(`서명 바이트 ${i} 위치 변조는 거부된다`, async () => {
const url = await buildCallbackUrl()
const parsed = parseAdMobCallbackUrl(url)
const tampered = new Uint8Array(parsed.signature)
tampered[i % tampered.length] ^= 0xff
let binary = ''
for (const b of tampered) binary += String.fromCharCode(b)
const tamperedUrl = await buildCallbackUrl({ signature: btoa(binary) })
await expect(verifyAdMobCallback(tamperedUrl, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
}
})
describe('E3. DER→RAW 서명 변환 (10)', () => {
it('유효한 DER ECDSA 서명은 64바이트 RAW로 변환된다', async () => {
const url = await buildCallbackUrl()
const parsed = parseAdMobCallbackUrl(url)
const raw = derEcdsaSignatureToRaw(parsed.signature)
expect(raw.byteLength).toBe(64)
})
const BAD_DER = [
['시퀀스 아님(0x00)', new Uint8Array([0x00, 0x02, 0x01, 0x01])],
['빈 입력', new Uint8Array([])],
['길이 불일치', new Uint8Array([0x30, 0x05, 0x02, 0x01, 0x01])],
['r 정수 깨짐', new Uint8Array([0x30, 0x03, 0x02, 0x01])],
['s 누락', new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x01])],
] as const
it.each(BAD_DER)(' malformed DER(%s)은 invalid_signature_encoding', (_label, bytes) => {
expect(() => derEcdsaSignatureToRaw(bytes as unknown as Uint8Array)).toThrow(AdMobSsvError)
})
for (let i = 0; i < 5; i += 1) {
it('동등 콜백(새 transaction_id) 반복 검증도 동일하게 통과한다', async () => {
const url = await buildCallbackUrl()
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).resolves.toBeTruthy()
})
}
})
describe('E4. 리플레이 방지 (60) — RED 목표', () => {
// 리플레이 캐시가 없다면 이 시나리오들은 RED로 남는다(의도).
for (let i = 0; i < 20; i += 1) {
it(`동일 transaction_id 재검증 ${i + 1}회차는 리플레이로 거부되어야 한다`, async () => {
const url = await buildCallbackUrl()
const first = await verifyAdMobCallback(url, fakeKeysFetch(fetch))
expect(first).toBeTruthy()
rememberVerifiedAdMobTransaction(first.params.get('transaction_id') ?? '')
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
}
for (let i = 0; i < 20; i += 1) {
it(`오래된 timestamp(${60 * (i + 1)}분 전) 콜백은 거부되어야 한다`, async () => {
const url = await buildCallbackUrl({
baseParams: { timestamp: String(Date.now() - 60 * 60 * 1000 * (i + 1)) },
})
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
}
for (let i = 0; i < 20; i += 1) {
it(`미래 timestamp(+${i + 1}시) 콜백은 거부되어야 한다`, async () => {
const url = await buildCallbackUrl({
baseParams: { timestamp: String(Date.now() + 60 * 60 * 1000 * (i + 1) + 60_000) },
})
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
}
})
})
// 총 시나리오 수: E(40+30+10+60=140)

View file

@ -0,0 +1,370 @@
// 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