136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
// 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)
|