d3ro-voice/apps/desktop/tests/red/ads/ad-settlement.e2e.test.ts
2026-08-29 18:33:45 +09:00

136 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)