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

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getChainService } from '../../src/main/services/ChainService'

View file

@ -3,7 +3,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { Buffer } from 'node:buffer'
import { ErrorCode, D3ROError } from '@d3ro/core/errors'
import { ErrorCode } from '@d3ro/core/errors'
import type { STTProviderType, STTProviderConfig } from '@d3ro/core/types'
import { getSTTManager, resetSTTManagerForTests } from '../../src/main/services/stt/STTManager'
import { pcmToWav, createProbeWav } from '../../src/main/services/stt/audio-utils'
@ -26,6 +26,33 @@ function makePcmBuffer(durationSeconds = 1.0, sampleRate = 16000): Buffer {
return buf
}
function makeCloudDriver(authenticated = true): D3ROCloudDriver {
return new D3ROCloudDriver({
getAccessToken: async () => authenticated ? 'user.jwt.token' : null,
getSupabaseUrl: () => 'https://project.supabase.co',
getAnonKey: () => 'anon-key',
})
}
function cloudPayload(
transcript: string,
overrides: Partial<{
confidence: number
language_code: string
duration_seconds: number
provider: string
}> = {},
) {
return {
transcript,
confidence: 0.98,
language_code: 'ko',
duration_seconds: 1,
provider: 'groq',
...overrides,
}
}
describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenarios)', () => {
beforeEach(() => {
initInMemoryConfig()
@ -153,20 +180,14 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
// =========================================================================
describe('Category 2: Multi-Provider Cloud Drivers Dispatch', () => {
it('16. D3ROCloudDriver sends audio to D3RO Cloud Gateway and returns structured transcript', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
const mockAudio = makePcmBuffer(1.0)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
text: 'D3RO Cloud Gateway transcription success.',
language: 'ko',
durationSeconds: 1.0,
provider: 'groq',
latencyMs: 120,
}),
json: async () => cloudPayload('D3RO Cloud Gateway transcription success.'),
})
)
@ -178,18 +199,14 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('17. D3ROCloudDriver testConnection returns success and latency on 200 OK', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true, latencyMs: 45, message: 'Connected' }),
})
vi.fn().mockResolvedValue({ ok: false, status: 400 })
)
const testRes = await driver.testConnection({ baseUrl: 'http://localhost:5000' })
expect(testRes.success).toBe(true)
expect(testRes.latencyMs).toBe(45)
expect(testRes.message).toContain('인증 경로 준비됨')
})
it('18. GroqDriver sends multipart request with Authorization Bearer header', async () => {
@ -340,14 +357,14 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('26. Driver testConnection handles network socket timeout gracefully', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockRejectedValue(new Error('Connection timed out after 10000ms'))
)
const res = await driver.testConnection({ baseUrl: 'http://unreachable-host:9999' })
expect(res.success).toBe(false)
expect(res.message).toContain('Connection timed out')
expect(res.message).toContain('연결 또는 인증에 실패')
})
it('27. DeepgramDriver handles smart formatting and punctuation options', async () => {
@ -811,24 +828,21 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('58. Success on primary cloud driver does NOT invoke local fallback', async () => {
const mgr = getSTTManager()
mgr.setProvider('d3ro-cloud')
mgr.setProviderConfig('d3ro-cloud', { baseUrl: 'http://localhost:5000' })
configSet('sttFallbackToLocal', true)
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'Primary Cloud Success' }),
json: async () => cloudPayload('Primary Cloud Success'),
})
)
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
const localSpy = vi.spyOn(getLocalSTTService(), 'transcribe')
const res = await mgr.transcribe(makePcmBuffer(1.0))
const res = await driver.transcribe(makePcmBuffer(1.0))
expect(res.text).toBe('Primary Cloud Success')
expect(localSpy).not.toHaveBeenCalled()
})
@ -882,13 +896,13 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
// =========================================================================
describe('Category 5: Concurrency, Quotas & Stress Workflows', () => {
it('61. Parallel execution of 5 audio transcriptions completes without race condition', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ text: 'Parallel transcription result', durationSeconds: 1.0 }),
json: async () => cloudPayload('Parallel transcription result'),
}))
)
@ -900,16 +914,16 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('62. High-concurrency burst (10 parallel requests) maintains data integrity', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(async (_, opts) => {
const body = opts.body as FormData
const file = body.get('file') as Blob
const file = body.get('audio') as Blob
return {
ok: true,
status: 200,
json: async () => ({ text: `Transcribed ${file.size} bytes`, durationSeconds: 1.0 }),
json: async () => cloudPayload(`Transcribed ${file.size} bytes`),
}
})
)
@ -942,7 +956,7 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('64. Long audio transcription processing time measurement is strictly positive', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(async () => {
@ -950,7 +964,7 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
return {
ok: true,
status: 200,
json: async () => ({ text: 'Measured latency', durationSeconds: 2.0 }),
json: async () => cloudPayload('Measured latency', { duration_seconds: 2 }),
}
})
)
@ -959,30 +973,35 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
expect(result.processingTime).toBeGreaterThanOrEqual(15)
})
it('65. Audio duration estimation fallback when API omits duration', async () => {
const driver = new D3ROCloudDriver()
it('65. Missing server duration is rejected instead of inventing metadata', async () => {
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'No duration in payload' }),
json: async () => ({
transcript: 'No duration in payload',
confidence: 0.98,
language_code: 'ko',
provider: 'groq',
}),
})
)
const pcm1Sec = makePcmBuffer(1.0, 16000)
const result = await driver.transcribe(pcm1Sec, {}, { baseUrl: 'http://localhost:5000' })
expect(result.duration).toBe(1) // 32000 bytes / 2 / 16000 = 1 sec
await expect(driver.transcribe(makePcmBuffer(1.0, 16000))).rejects.toMatchObject({
code: ErrorCode.STTTranscriptionFailed,
})
})
it('66. Multiple sequential requests with varying sample rates succeed', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'Sample rate OK' }),
json: async () => cloudPayload('Sample rate OK'),
})
)
@ -1005,13 +1024,13 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('69. Zero-byte audio buffer rejected with STTNoAudioData error', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: '' }),
json: async () => cloudPayload(''),
})
)
await expect(driver.transcribe(Buffer.alloc(0), {}, { baseUrl: 'http://localhost:5000' })).rejects.toMatchObject({
@ -1020,13 +1039,13 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('70. Whitespace-only transcription response rejected with STTNoAudioData error', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: '\n\t \r ' }),
json: async () => cloudPayload('\n\t \r '),
})
)
await expect(driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000' })).rejects.toMatchObject({
@ -1035,14 +1054,14 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('71. Large 1-minute audio buffer (1.92MB PCM) handles memory without truncation', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
const bigPcm = Buffer.alloc(16000 * 2 * 60) // 1.92 MB
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: '1 minute transcription complete', durationSeconds: 60.0 }),
json: async () => cloudPayload('1 minute transcription complete', { duration_seconds: 60 }),
})
)
const res = await driver.transcribe(bigPcm, {}, { baseUrl: 'http://localhost:5000' })
@ -1075,32 +1094,32 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBeUndefined()
})
it('74. D3ROCloudDriver uses cloudAuthToken from ConfigService if not specified in config', async () => {
configSet('cloudAuthToken', 'jwt_user_session_token_12345')
const driver = new D3ROCloudDriver()
it('74. D3ROCloudDriver uses the latest authenticated Supabase session token', async () => {
const driver = makeCloudDriver()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'Token injected' }),
json: async () => cloudPayload('Token injected'),
})
vi.stubGlobal('fetch', fetchSpy)
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000' })
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer jwt_user_session_token_12345')
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer user.jwt.token')
expect(fetchSpy.mock.calls[0][1].headers.apikey).toBe('anon-key')
})
it('75. D3ROCloudDriver prioritizes apiKey passed in config over cloudAuthToken', async () => {
configSet('cloudAuthToken', 'jwt_user_session_token_12345')
const driver = new D3ROCloudDriver()
it('75. D3ROCloudDriver ignores user-supplied provider tokens and base URLs', async () => {
const driver = makeCloudDriver()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'Explicit token used' }),
json: async () => cloudPayload('Server session used'),
})
vi.stubGlobal('fetch', fetchSpy)
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000', apiKey: 'explicit_token' })
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer explicit_token')
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer user.jwt.token')
expect(fetchSpy.mock.calls[0][0]).toBe('https://project.supabase.co/functions/v1/stt-proxy')
})
})
@ -1109,67 +1128,67 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
// =========================================================================
describe('Category 6: Domain Vocabulary & Action Pipelines', () => {
it('76. Korean language code "ko" is properly injected into form data', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: '한국어 전사', language: 'ko' }),
json: async () => cloudPayload('한국어 전사', { language_code: 'ko' }),
})
vi.stubGlobal('fetch', fetchSpy)
await driver.transcribe(makePcmBuffer(1.0), { language: 'ko' }, { baseUrl: 'http://localhost:5000' })
const formData = fetchSpy.mock.calls[0][1].body as FormData
expect(formData.get('language')).toBe('ko')
expect(formData.get('language_code')).toBe('ko')
})
it('77. English language code "en" is injected into form data', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'English transcript', language: 'en' }),
json: async () => cloudPayload('English transcript', { language_code: 'en' }),
})
vi.stubGlobal('fetch', fetchSpy)
await driver.transcribe(makePcmBuffer(1.0), { language: 'en' }, { baseUrl: 'http://localhost:5000' })
const formData = fetchSpy.mock.calls[0][1].body as FormData
expect(formData.get('language')).toBe('en')
expect(formData.get('language_code')).toBe('en')
})
it('78. Japanese language code "ja" is injected into form data', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: '日本語の文字起こし', language: 'ja' }),
json: async () => cloudPayload('日本語の文字起こし', { language_code: 'ja' }),
})
vi.stubGlobal('fetch', fetchSpy)
await driver.transcribe(makePcmBuffer(1.0), { language: 'ja' }, { baseUrl: 'http://localhost:5000' })
const formData = fetchSpy.mock.calls[0][1].body as FormData
expect(formData.get('language')).toBe('ja')
expect(formData.get('language_code')).toBe('ja')
})
it('79. Auto language detection "auto" does not constrain language parameter', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'Auto detected text', language: 'ko' }),
json: async () => cloudPayload('Auto detected text'),
})
vi.stubGlobal('fetch', fetchSpy)
await driver.transcribe(makePcmBuffer(1.0), { language: 'auto' }, { baseUrl: 'http://localhost:5000' })
const formData = fetchSpy.mock.calls[0][1].body as FormData
expect(formData.get('language')).toBeNull()
expect(formData.get('language_code')).toBeNull()
})
it('80. Domain vocabulary prompt injected into initialPrompt parameter', async () => {
const driver = new D3ROCloudDriver()
it('80. Domain vocabulary stays server-managed and is not client-injected', async () => {
const driver = makeCloudDriver()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: '쿠버네티스, 리액트, D3RO 전사 완료' }),
json: async () => cloudPayload('쿠버네티스, 리액트, D3RO 전사 완료'),
})
vi.stubGlobal('fetch', fetchSpy)
@ -1179,21 +1198,21 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
{ baseUrl: 'http://localhost:5000' }
)
const formData = fetchSpy.mock.calls[0][1].body as FormData
expect(formData.get('prompt')).toBe('Kubernetes, React, Next.js, D3RO Voice')
expect(formData.get('prompt')).toBeNull()
})
it('81. Custom model selection overrides default model in request', async () => {
const driver = new D3ROCloudDriver()
it('81. Managed STT model stays server-selected despite client config', async () => {
const driver = makeCloudDriver()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'Model override OK' }),
json: async () => cloudPayload('Server model OK'),
})
vi.stubGlobal('fetch', fetchSpy)
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000', modelId: 'whisper-large-v3-turbo' })
const formData = fetchSpy.mock.calls[0][1].body as FormData
expect(formData.get('model')).toBe('whisper-large-v3-turbo')
expect(formData.get('model')).toBeNull()
})
it('82. Medical vocabulary prompt recognition flow', async () => {
@ -1256,13 +1275,13 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('85. Punctuation formatting preserved in final output', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: '안녕하세요? 오늘 회의는 오후 2시입니다!' }),
json: async () => cloudPayload('안녕하세요? 오늘 회의는 오후 2시입니다!'),
})
)
@ -1271,13 +1290,13 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('86. Number and currency formatting preserved in transcript', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: '총 결제 금액은 45,000원입니다.' }),
json: async () => cloudPayload('총 결제 금액은 45,000원입니다.'),
})
)
@ -1307,13 +1326,13 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('88. Non-standard special symbols in transcript do not break string serialization', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: '특수문자 테스트: <>&"\'!@#$%^&*()' }),
json: async () => cloudPayload('특수문자 테스트: <>&"\'!@#$%^&*()'),
})
)
@ -1322,11 +1341,11 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('89. Empty prompt does not insert "prompt" key in form data', async () => {
const driver = new D3ROCloudDriver()
const driver = makeCloudDriver()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'Clean prompt' }),
json: async () => cloudPayload('Clean prompt'),
})
vi.stubGlobal('fetch', fetchSpy)
@ -1383,17 +1402,17 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
expect(calculatedCost).toBeCloseTo(0.00000417, 7)
})
it('95. API Base URL trailing slashes are trimmed cleanly without double-slash paths', async () => {
const driver = new D3ROCloudDriver()
it('95. User-supplied API Base URL cannot replace the Supabase Edge origin', async () => {
const driver = makeCloudDriver()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ text: 'URL normalized' }),
json: async () => cloudPayload('URL normalized'),
})
vi.stubGlobal('fetch', fetchSpy)
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000///' })
expect(fetchSpy.mock.calls[0][0]).toBe('http://localhost:5000/api/stt/transcribe')
expect(fetchSpy.mock.calls[0][0]).toBe('https://project.supabase.co/functions/v1/stt-proxy')
})
it('96. Custom Driver base URL without protocol defaults or handles cleanly', async () => {
@ -1466,28 +1485,21 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
})
it('105. Full End-to-End lifecycle simulation: Config -> Audio capture -> Cloud STT -> Result delivery', async () => {
const mgr = getSTTManager()
mgr.setProvider('d3ro-cloud')
mgr.setProviderConfig('d3ro-cloud', { baseUrl: 'http://localhost:5000', apiKey: 'user_jwt' })
const driver = makeCloudDriver()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
text: 'D3RO 음성 인식이 완료되었습니다.',
language: 'ko',
durationSeconds: 3.5,
provider: 'groq',
latencyMs: 140,
cost: 0.000029,
json: async () => cloudPayload('D3RO 음성 인식이 완료되었습니다.', {
duration_seconds: 3.5,
}),
})
)
const audio = makePcmBuffer(3.5, 16000)
const result = await mgr.transcribe(audio, { language: 'ko', initialPrompt: 'D3RO Voice' })
const result = await driver.transcribe(audio, { language: 'ko', initialPrompt: 'D3RO Voice' })
expect(result.text).toBe('D3RO 음성 인식이 완료되었습니다.')
expect(result.language).toBe('ko')

View file

@ -6,8 +6,6 @@ import {
generateLicenseKeyPair,
issueSignedLicenseKey,
verifySignedLicenseKey,
createDefaultTrialPayload,
DEFAULT_LICENSE_PUBLIC_KEY,
} from '@d3ro/core/utils/crypto-license'
import { getLicenseService } from '../../src/main/services/LicenseService'
import { useRedHarness } from './harness'
@ -33,13 +31,71 @@ describe('Ed25519 암호화 라이센스 & Reverse Trial 유스케이스', () =>
expect(key.startsWith('D3RO-LIC-')).toBe(true)
// 검증 성공
const verification = verifySignedLicenseKey(key, 'test-machine-id-1234', publicKeyPem)
const verification = verifySignedLicenseKey(key, 'test-machine-id-1234', publicKeyPem, {
environment: 'production',
})
expect(verification.valid).toBe(true)
expect(verification.tier).toBe('pro_plus')
expect(verification.reason).toBe('valid')
expect(verification.payload?.customerEmail).toBe('alice@enterprise.com')
})
it('운영 환경은 레거시 접두어 키를 명시적 허용 옵션이 있어도 거부한다', () => {
const verification = verifySignedLicenseKey(
'D3RO-PRO-TEST-KEY1',
undefined,
undefined,
{ allowDevelopmentKeys: true, environment: 'production' },
)
expect(verification.valid).toBe(false)
expect(verification.tier).toBe('free')
expect(verification.reason).toBe('corrupted_token')
})
it('테스트 환경은 명시적으로 허용한 정확한 개발 fixture만 인정한다', () => {
const knownFixture = verifySignedLicenseKey(
'D3RO-PRO-TEST-KEY1',
undefined,
undefined,
{ allowDevelopmentKeys: true, environment: 'test' },
)
const forgedPrefix = verifySignedLicenseKey(
'D3RO-PRO-ATTACKER-KEY',
undefined,
undefined,
{ allowDevelopmentKeys: true, environment: 'test' },
)
expect(knownFixture.valid).toBe(true)
expect(knownFixture.tier).toBe('pro')
expect(knownFixture.reason).toBe('dev_key')
expect(forgedPrefix.valid).toBe(false)
expect(forgedPrefix.tier).toBe('free')
})
it('운영 공개키가 없으면 서명 형식이 맞아도 fail-closed 한다', () => {
const { privateKeyPem } = generateLicenseKeyPair()
const key = issueSignedLicenseKey(
{
licenseId: 'lic-no-production-key',
tier: 'enterprise',
customerEmail: 'release@example.test',
issuedAt: Date.now(),
expiresAt: null,
machineId: null,
},
privateKeyPem,
)
const verification = verifySignedLicenseKey(key, undefined, undefined, {
environment: 'production',
})
expect(verification.valid).toBe(false)
expect(verification.reason).toBe('invalid_signature')
expect(verification.message).toContain('not configured')
})
it('머신 ID가 일치하지 않으면 machine_mismatch 로 실패한다', () => {
const { publicKeyPem, privateKeyPem } = generateLicenseKeyPair()
const payload = {

View file

@ -1,5 +1,4 @@
import { describe, it, expect } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getCustomInstructionService } from '../../src/main/services/CustomInstructionService'
import { registerInstructionHandlers } from '../../src/main/ipc/instruction-handlers'

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService'

View file

@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { ErrorCode } from '@d3ro/core/errors'
import { getRAGService } from '../../src/main/services/RAGService'
import { getFileTranscriptionService } from '../../src/main/services/FileTranscriptionService'
import { useRedHarness } from './harness'

View file

@ -1,5 +1,4 @@
import { describe, it, expect, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { Feature } from '@d3ro/core/types'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getHistoryService } from '../../src/main/services/HistoryService'

View file

@ -26,6 +26,9 @@ vi.mock('../../src/main/services/CloudSyncService', () => ({
getCloudSyncService: () => ({
isEnabled: () => true,
isAuthenticated: () => cloudPorts.authenticated,
getAccessToken: async () => cloudPorts.authenticated ? 'fx.authenticated.jwt' : null,
getSupabaseUrl: () => 'https://project.supabase.co',
getAnonKey: () => 'fx-anon-key',
invokeFunction: (name: string, body: Record<string, unknown>) => cloudPorts.invoke(name, body),
signInAnonymously: async () => {
throw new Error('fx.anon.not-supported')
@ -54,7 +57,7 @@ describe('유스케이스: STT/LLM 모델 I/O 실패·빈값·기형 응답', ()
it('STT 프록시 에러는 STTTranscriptionFailed 다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({ data: null, error: { message: 'fx.stt.proxy-down' } })
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503 })))
await expect(getCloudSTTService().transcribe(Buffer.from('pcm'))).rejects.toMatchObject({
code: ErrorCode.STTTranscriptionFailed,
})
@ -62,10 +65,17 @@ describe('유스케이스: STT/LLM 모델 I/O 실패·빈값·기형 응답', ()
it('STT 빈 텍스트는 성공 객체가 아니라 에러다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({
data: { text: FX.STT_EMPTY, segments: [], language: 'ko' },
error: null,
})
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
transcript: FX.STT_EMPTY,
confidence: 0.9,
language_code: 'ko',
duration_seconds: 1,
provider: 'fixture',
}),
})))
await expect(getCloudSTTService().transcribe(Buffer.from('pcm'))).rejects.toMatchObject({
code: ErrorCode.STTNoAudioData,
})
@ -73,7 +83,11 @@ describe('유스케이스: STT/LLM 모델 I/O 실패·빈값·기형 응답', ()
it('STT 기형 페이로드(text 없음)는 파싱 실패다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({ data: FX.STT_MALFORMED_SHAPE, error: null })
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => FX.STT_MALFORMED_SHAPE,
})))
await expect(getCloudSTTService().transcribe(Buffer.from('pcm'))).rejects.toMatchObject({
code: ErrorCode.STTTranscriptionFailed,
})
@ -81,10 +95,17 @@ describe('유스케이스: STT/LLM 모델 I/O 실패·빈값·기형 응답', ()
it('STT 정상 픽스처는 text 가 픽스처 토큰이다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({
data: { text: FX.STT_OK, segments: [], language: 'ko', duration: 1, processingTime: 1 },
error: null,
})
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
transcript: FX.STT_OK,
confidence: 0.99,
language_code: 'ko',
duration_seconds: 1,
provider: 'fixture',
}),
})))
const result = await getCloudSTTService().transcribe(Buffer.from('pcm'))
expect(result.text).toBe(FX.STT_OK)
expect(result.text).not.toBe(USER_TEXT.KO)