feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,9 +1,7 @@
|
|||
// apps/desktop/tests/unit/AdMediationEngine.spec.ts
|
||||
// Comprehensive Real Service Test Suite for 10+ Multi-Ad Network Mediation & Settlement
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { getAdMediationEngine, AdMediationEngine } from '../../src/main/services/ads/AdMediationEngine'
|
||||
import { getAdSettlementService, AdSettlementService } from '../../src/main/services/ads/AdSettlementService'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { AdMediationConfig } from '@d3ro/core/types'
|
||||
import { getAdMediationEngine } from '../../src/main/services/ads/AdMediationEngine'
|
||||
import { getAdSettlementService } from '../../src/main/services/ads/AdSettlementService'
|
||||
import { EthicalAdsAdapter } from '../../src/main/services/ads/EthicalAdsAdapter'
|
||||
import { CarbonAdsAdapter } from '../../src/main/services/ads/CarbonAdsAdapter'
|
||||
import { PlaywireAdapter } from '../../src/main/services/ads/PlaywireAdapter'
|
||||
|
|
@ -15,182 +13,176 @@ import { PubMaticAdapter } from '../../src/main/services/ads/PubMaticAdapter'
|
|||
import { MintegralAdapter } from '../../src/main/services/ads/MintegralAdapter'
|
||||
import { DirectHouseSponsorAdapter } from '../../src/main/services/ads/DirectHouseSponsorAdapter'
|
||||
|
||||
describe('10+ Multi-Ad Network Adapters & Real Service Tests', () => {
|
||||
it('1. EthicalAds Adapter bids on developer dock slots', async () => {
|
||||
const adapter = new EthicalAdsAdapter()
|
||||
await adapter.init()
|
||||
const bid = await adapter.requestBid({ placement: 'bottom_dock_banner', format: 'banner_dock' })
|
||||
expect(bid.hasBid).toBe(true)
|
||||
expect(bid.bidEcpm).toBeGreaterThanOrEqual(3.0)
|
||||
expect(bid.creative?.networkId).toBe('ethical_ads')
|
||||
expect(bid.creative?.title).toContain('MongoDB')
|
||||
})
|
||||
const adapterCases = [
|
||||
['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
|
||||
|
||||
it('2. Carbon Ads Adapter provides tech native single-unit creatives', async () => {
|
||||
const adapter = new CarbonAdsAdapter()
|
||||
describe('desktop ad provider boundaries', () => {
|
||||
it.each(adapterCases)('%s returns no bid until a real provider is integrated', async (_name, adapter) => {
|
||||
await adapter.init()
|
||||
const bid = await adapter.requestBid({ placement: 'bottom_dock_banner', format: 'banner_dock' })
|
||||
expect(bid.hasBid).toBe(true)
|
||||
expect(bid.bidEcpm).toBeGreaterThanOrEqual(3.5)
|
||||
expect(bid.creative?.networkId).toBe('carbon_ads')
|
||||
expect(bid.creative?.title).toContain('Linear')
|
||||
})
|
||||
const bid = await adapter.requestBid({
|
||||
placement: adapter.supportedFormats.includes('rewarded_video')
|
||||
? 'rewarded_video_quota'
|
||||
: 'bottom_dock_banner',
|
||||
format: adapter.supportedFormats[0],
|
||||
})
|
||||
|
||||
it('3. Playwire RAMP Adapter participates in desktop header bidding', async () => {
|
||||
const adapter = new PlaywireAdapter()
|
||||
await adapter.init()
|
||||
const bid = await adapter.requestBid({ placement: 'bottom_dock_banner', format: 'banner_dock' })
|
||||
expect(bid.hasBid).toBe(true)
|
||||
expect(bid.bidEcpm).toBeGreaterThanOrEqual(4.5)
|
||||
expect(bid.creative?.networkId).toBe('playwire')
|
||||
})
|
||||
|
||||
it('4. Unity LevelPlay Adapter delivers high-yield rewarded video ads', async () => {
|
||||
const adapter = new UnityAdsAdapter()
|
||||
await adapter.init()
|
||||
const bid = await adapter.requestBid({ placement: 'rewarded_video_quota', format: 'rewarded_video' })
|
||||
expect(bid.hasBid).toBe(true)
|
||||
expect(bid.bidEcpm).toBeGreaterThanOrEqual(6.0)
|
||||
expect(bid.creative?.networkId).toBe('unity_ads')
|
||||
expect(bid.creative?.rewardTokens).toBe(50)
|
||||
})
|
||||
|
||||
it('5. AppLovin MAX Adapter provides competitive in-app bidding', async () => {
|
||||
const adapter = new AppLovinAdapter()
|
||||
await adapter.init()
|
||||
const bid = await adapter.requestBid({ placement: 'rewarded_video_quota', format: 'rewarded_video' })
|
||||
expect(bid.hasBid).toBe(true)
|
||||
expect(bid.bidEcpm).toBeGreaterThanOrEqual(5.5)
|
||||
expect(bid.creative?.networkId).toBe('applovin_max')
|
||||
})
|
||||
|
||||
it('6. Google Ad Manager 360 Adapter offers reliable global demand', async () => {
|
||||
const adapter = new GoogleAdManagerAdapter()
|
||||
await adapter.init()
|
||||
const bid = await adapter.requestBid({ placement: 'bottom_dock_banner', format: 'banner_dock' })
|
||||
expect(bid.hasBid).toBe(true)
|
||||
expect(bid.bidEcpm).toBeGreaterThanOrEqual(2.0)
|
||||
expect(bid.creative?.networkId).toBe('google_ad_manager')
|
||||
})
|
||||
|
||||
it('7. InMobi Adapter handles programmatic exchange bids', async () => {
|
||||
const adapter = new InMobiAdapter()
|
||||
await adapter.init()
|
||||
const bid = await adapter.requestBid({ placement: 'bottom_dock_banner', format: 'banner_dock' })
|
||||
expect(bid.hasBid).toBe(true)
|
||||
expect(bid.bidEcpm).toBeGreaterThanOrEqual(2.8)
|
||||
expect(bid.creative?.networkId).toBe('inmobi')
|
||||
})
|
||||
|
||||
it('8. PubMatic OpenWrap SSP Adapter responds with Prebid bids', async () => {
|
||||
const adapter = new PubMaticAdapter()
|
||||
await adapter.init()
|
||||
const bid = await adapter.requestBid({ placement: 'bottom_dock_banner', format: 'banner_dock' })
|
||||
expect(bid.hasBid).toBe(true)
|
||||
expect(bid.bidEcpm).toBeGreaterThanOrEqual(3.0)
|
||||
expect(bid.creative?.networkId).toBe('pubmatic')
|
||||
})
|
||||
|
||||
it('9. Mintegral Adapter delivers APAC/global video demand', async () => {
|
||||
const adapter = new MintegralAdapter()
|
||||
await adapter.init()
|
||||
const bid = await adapter.requestBid({ placement: 'rewarded_video_quota', format: 'rewarded_video' })
|
||||
expect(bid.hasBid).toBe(true)
|
||||
expect(bid.bidEcpm).toBeGreaterThanOrEqual(4.0)
|
||||
expect(bid.creative?.networkId).toBe('mintegral')
|
||||
})
|
||||
|
||||
it('10. Direct House Sponsor Adapter provides 100% margin premium AI ads', async () => {
|
||||
const adapter = new DirectHouseSponsorAdapter()
|
||||
await adapter.init()
|
||||
const bid = await adapter.requestBid({ placement: 'bottom_dock_banner', format: 'banner_dock' })
|
||||
expect(bid.hasBid).toBe(true)
|
||||
expect(bid.bidEcpm).toBeGreaterThanOrEqual(12.0)
|
||||
expect(bid.creative?.networkId).toBe('direct_sponsor')
|
||||
expect(bid).toEqual({
|
||||
hasBid: false,
|
||||
bidEcpm: 0,
|
||||
latencyMs: 0,
|
||||
error: 'provider_not_integrated',
|
||||
})
|
||||
await expect(adapter.reportRewardCompletion('forged-ad-id')).resolves.toEqual({
|
||||
success: false,
|
||||
tokenReward: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdMediationEngine Header Bidding Auction & Telemetry', () => {
|
||||
let engine: AdMediationEngine
|
||||
describe('AdMediationEngine fail-closed auction and telemetry', () => {
|
||||
const engine = getAdMediationEngine()
|
||||
const settlement = getAdSettlementService()
|
||||
const knownNetworks = engine.getConfig().networks
|
||||
let disabledNetworks: AdMediationConfig['networks']
|
||||
|
||||
beforeEach(() => {
|
||||
engine = getAdMediationEngine()
|
||||
disabledNetworks = knownNetworks.map((network) => ({
|
||||
...network,
|
||||
enabled: false,
|
||||
}))
|
||||
engine.setConfig({ networks: disabledNetworks, houseAdFallback: false })
|
||||
})
|
||||
|
||||
it('executes parallel header bidding auction across all active networks', async () => {
|
||||
const auctionResult = await engine.runAuction({
|
||||
it('returns a real no-fill result instead of inventing a winner', async () => {
|
||||
const result = await engine.runAuction({
|
||||
placement: 'bottom_dock_banner',
|
||||
format: 'banner_dock',
|
||||
floorEcpm: 2.0,
|
||||
floorEcpm: 2,
|
||||
auctionTimeoutMs: 800,
|
||||
})
|
||||
|
||||
expect(auctionResult).toBeDefined()
|
||||
expect(auctionResult.winner).toBeDefined()
|
||||
expect(auctionResult.winningBidEcpm).toBeGreaterThanOrEqual(2.0)
|
||||
expect(auctionResult.participatingBids.length).toBeGreaterThanOrEqual(6)
|
||||
expect(auctionResult.totalAuctionLatencyMs).toBeLessThanOrEqual(1200)
|
||||
expect(result.winner).toBeNull()
|
||||
expect(result.winningBidEcpm).toBe(0)
|
||||
expect(result.participatingBids).toEqual([])
|
||||
expect(result.totalAuctionLatencyMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('records ad impression and triggers adapter beacon', () => {
|
||||
expect(() => {
|
||||
engine.recordImpression({
|
||||
adId: 'test_ad_001',
|
||||
format: 'banner_dock',
|
||||
network: 'ethical_ads',
|
||||
earnedEcpm: 4.2,
|
||||
})
|
||||
}).not.toThrow()
|
||||
it('keeps provider shells no-bid even if an operator enables them prematurely', async () => {
|
||||
engine.setConfig({
|
||||
networks: disabledNetworks.map((network) => ({ ...network, enabled: true })),
|
||||
})
|
||||
|
||||
const result = await engine.runAuction({
|
||||
placement: 'bottom_dock_banner',
|
||||
format: 'banner_dock',
|
||||
floorEcpm: 0.01,
|
||||
auctionTimeoutMs: 800,
|
||||
})
|
||||
|
||||
expect(result.winner).toBeNull()
|
||||
expect(result.winningBidEcpm).toBe(0)
|
||||
expect(result.participatingBids.length).toBeGreaterThan(0)
|
||||
expect(result.participatingBids.every((bid) => bid.status === 'no_bid')).toBe(true)
|
||||
})
|
||||
|
||||
it('records ad click-through tracking event', () => {
|
||||
expect(() => {
|
||||
engine.recordClick('test_ad_001', 'ethical_ads')
|
||||
}).not.toThrow()
|
||||
it('does not enable an adapter merely because its config entry is omitted', async () => {
|
||||
engine.setConfig({ networks: [] })
|
||||
|
||||
const result = await engine.runAuction({
|
||||
placement: 'bottom_dock_banner',
|
||||
format: 'banner_dock',
|
||||
})
|
||||
|
||||
expect(result.winner).toBeNull()
|
||||
expect(result.participatingBids).toEqual([])
|
||||
})
|
||||
|
||||
it('dispenses +50 tokens upon rewarded video completion and enforces cooldown', async () => {
|
||||
const rewardRes = await engine.claimReward('test_video_ad', 'unity_ads')
|
||||
expect(rewardRes.success).toBe(true)
|
||||
expect(rewardRes.tokensAdded).toBe(50)
|
||||
it('returns a defensive config copy', () => {
|
||||
const exposedConfig = engine.getConfig()
|
||||
exposedConfig.networks[0].enabled = true
|
||||
|
||||
// Immediate second claim should be rejected by cooldown
|
||||
const cooldownRes = await engine.claimReward('test_video_ad_2', 'unity_ads')
|
||||
expect(cooldownRes.success).toBe(false)
|
||||
expect(cooldownRes.tokensAdded).toBe(0)
|
||||
expect(cooldownRes.nextAvailableAt).toBeDefined()
|
||||
expect(engine.getConfig().networks[0].enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores renderer-forged impressions and clicks', () => {
|
||||
const before = settlement.getRevenueStats('2026-08')
|
||||
|
||||
engine.recordImpression({
|
||||
adId: 'forged-ad-id',
|
||||
format: 'banner_dock',
|
||||
network: 'ethical_ads',
|
||||
earnedEcpm: 999,
|
||||
})
|
||||
engine.recordClick('forged-ad-id', 'ethical_ads')
|
||||
|
||||
const after = settlement.getRevenueStats('2026-08')
|
||||
expect(after.totalImpressions).toBe(before.totalImpressions)
|
||||
expect(after.totalClicks).toBe(before.totalClicks)
|
||||
expect(after.totalRevenueUsd).toBe(before.totalRevenueUsd)
|
||||
})
|
||||
|
||||
it('never grants desktop quota without server-verified completion', async () => {
|
||||
await expect(engine.claimReward('forged-reward-id', 'unity_ads')).resolves.toEqual({
|
||||
success: false,
|
||||
tokensAdded: 0,
|
||||
newTotalQuota: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdSettlementService Revenue Ledger & Tax Withholding', () => {
|
||||
let settlement: AdSettlementService
|
||||
describe('AdSettlementService fail-closed ledger', () => {
|
||||
const settlement = getAdSettlementService()
|
||||
|
||||
beforeEach(() => {
|
||||
settlement = getAdSettlementService()
|
||||
it('does not ship personal payout details or pretend providers are configured', () => {
|
||||
expect(settlement.getPublisherAccount()).toEqual({
|
||||
accountEmail: '',
|
||||
beneficiaryName: '',
|
||||
payoutBank: '',
|
||||
payoutAccountNumber: '',
|
||||
taxRegistrationNumber: '',
|
||||
paypalEmail: '',
|
||||
networksConfigured: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('retrieves registered publisher account metadata for yunchanpaca@gmail.com', () => {
|
||||
const account = settlement.getPublisherAccount()
|
||||
expect(account.accountEmail).toBe('yunchanpaca@gmail.com')
|
||||
expect(account.payoutBank).toContain('KB국민은행')
|
||||
expect(account.networksConfigured).toBe(10)
|
||||
})
|
||||
|
||||
it('generates revenue statistics and computes Korean 3.3% withholding tax', () => {
|
||||
it('does not synthesize revenue, fill rate, or settlement history', () => {
|
||||
const stats = settlement.getRevenueStats('2026-08')
|
||||
expect(stats.totalRevenueUsd).toBeGreaterThan(0)
|
||||
expect(stats.avgEcpm).toBeGreaterThan(0)
|
||||
expect(stats.settlements.length).toBeGreaterThanOrEqual(5)
|
||||
|
||||
const first = stats.settlements[0]
|
||||
expect(first.withholdingTaxRate).toBe(0.033)
|
||||
expect(first.netPayoutKrw).toBe(Math.round(first.netRevenueUsd * 1350))
|
||||
expect(stats.totalImpressions).toBe(0)
|
||||
expect(stats.totalClicks).toBe(0)
|
||||
expect(stats.totalCompletions).toBe(0)
|
||||
expect(stats.totalRevenueUsd).toBe(0)
|
||||
expect(stats.avgEcpm).toBe(0)
|
||||
expect(stats.fillRatePercent).toBe(0)
|
||||
expect(stats.networkBreakdown).toEqual([])
|
||||
expect(stats.settlements).toEqual([])
|
||||
})
|
||||
|
||||
it('processes payout request and updates status to paid', () => {
|
||||
const res = settlement.requestPayout('stl_202607_direct_sponsor')
|
||||
expect(res.success).toBe(true)
|
||||
expect(res.settlement?.payoutStatus).toBe('paid')
|
||||
expect(res.message).toContain('KB국민은행')
|
||||
it('rejects payout without an external settlement provider', () => {
|
||||
expect(settlement.requestPayout('forged-settlement-id')).toEqual({
|
||||
success: false,
|
||||
message: 'external_settlement_not_configured',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores invalid or unattested settlement events', () => {
|
||||
const before = settlement.getRevenueStats('2026-08')
|
||||
|
||||
settlement.recordImpression('', 10)
|
||||
settlement.recordImpression('forged-network', Number.NaN)
|
||||
settlement.recordImpression('forged-network', -10)
|
||||
settlement.recordClick('forged-network')
|
||||
settlement.recordCompletion('forged-network')
|
||||
|
||||
expect(settlement.getRevenueStats('2026-08')).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
252
apps/desktop/tests/unit/checkout-flow.spec.ts
Normal file
252
apps/desktop/tests/unit/checkout-flow.spec.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { IPCResult } from '@d3ro/core/errors'
|
||||
import type { CheckoutSessionResult, SubscriptionStatusResult } from '@d3ro/core/types'
|
||||
import {
|
||||
CheckoutFlowController,
|
||||
type CheckoutFlowPorts,
|
||||
isTrustedStripeCheckoutUrl
|
||||
} from '../../src/renderer/components/payment/checkout-flow'
|
||||
|
||||
type CreateResult = IPCResult<CheckoutSessionResult>
|
||||
type StatusResult = IPCResult<SubscriptionStatusResult>
|
||||
|
||||
const trustedUrl = 'https://checkout.stripe.com/c/pay/cs_test_renderer_safe#fragment'
|
||||
const checkoutSuccess: CreateResult = {
|
||||
success: true,
|
||||
data: { checkoutUrl: trustedUrl, provider: 'stripe', status: 'pending' }
|
||||
}
|
||||
const openSuccess: IPCResult<void> = { success: true, data: undefined }
|
||||
const activeSubscription: StatusResult = {
|
||||
success: true,
|
||||
data: { tier: 'pro_plus', valid: true, expiresAt: Date.parse('2099-01-01T00:00:00.000Z') }
|
||||
}
|
||||
const ipcFailure = {
|
||||
success: false as const,
|
||||
error: { code: 999, message: 'private provider details' }
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('renderer checkout flow', () => {
|
||||
let createCheckoutSession: ReturnType<typeof vi.fn>
|
||||
let openExternal: ReturnType<typeof vi.fn>
|
||||
let getSubscriptionStatus: ReturnType<typeof vi.fn>
|
||||
let controller: CheckoutFlowController
|
||||
|
||||
beforeEach(() => {
|
||||
createCheckoutSession = vi.fn().mockResolvedValue(checkoutSuccess)
|
||||
openExternal = vi.fn().mockResolvedValue(openSuccess)
|
||||
getSubscriptionStatus = vi.fn().mockResolvedValue(activeSubscription)
|
||||
const ports: CheckoutFlowPorts = {
|
||||
createCheckoutSession,
|
||||
openExternal,
|
||||
getSubscriptionStatus
|
||||
}
|
||||
controller = new CheckoutFlowController(ports)
|
||||
})
|
||||
|
||||
it('opens only the authenticated IPC checkout result and waits for server confirmation', async () => {
|
||||
const opened = await controller.start('pro')
|
||||
|
||||
expect(opened).toBe(true)
|
||||
expect(createCheckoutSession).toHaveBeenCalledWith({ tier: 'pro', provider: 'stripe' })
|
||||
expect(openExternal).toHaveBeenCalledWith({ url: trustedUrl })
|
||||
expect(getSubscriptionStatus).not.toHaveBeenCalled()
|
||||
expect(controller.getState()).toEqual({
|
||||
phase: 'awaiting',
|
||||
error: null,
|
||||
verifiedTier: null
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when checkout IPC returns a provider error', async () => {
|
||||
createCheckoutSession.mockResolvedValue(ipcFailure)
|
||||
|
||||
await controller.start('pro')
|
||||
|
||||
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'checkout-failed' })
|
||||
expect(openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when checkout IPC throws a network error', async () => {
|
||||
createCheckoutSession.mockRejectedValue(new Error('private network details'))
|
||||
|
||||
await controller.start('pro')
|
||||
|
||||
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'network-failed' })
|
||||
expect(openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
checkoutUrl: 'http://checkout.stripe.com/c/pay/cs_test_bad',
|
||||
provider: 'stripe',
|
||||
status: 'pending'
|
||||
},
|
||||
{ checkoutUrl: 'https://evil.test/c/pay/cs_test_bad', provider: 'stripe', status: 'pending' },
|
||||
{ checkoutUrl: trustedUrl, provider: 'toss', status: 'pending' },
|
||||
{ checkoutUrl: trustedUrl, provider: 'stripe', status: 'completed' }
|
||||
])('rejects an unsafe or forged checkout response %#', async (data) => {
|
||||
createCheckoutSession.mockResolvedValue({ success: true, data })
|
||||
|
||||
await controller.start('pro')
|
||||
|
||||
expect(controller.getState()).toMatchObject({
|
||||
phase: 'idle',
|
||||
error: 'unsafe-checkout-response'
|
||||
})
|
||||
expect(openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the OS cannot open the checkout page', async () => {
|
||||
openExternal.mockResolvedValue(ipcFailure)
|
||||
|
||||
await controller.start('pro')
|
||||
|
||||
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'open-failed' })
|
||||
})
|
||||
|
||||
it('fails closed when opening the checkout page throws', async () => {
|
||||
openExternal.mockRejectedValue(new Error('private shell details'))
|
||||
|
||||
await controller.start('pro')
|
||||
|
||||
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'open-failed' })
|
||||
})
|
||||
|
||||
it('coalesces duplicate checkout clicks into one IPC request', async () => {
|
||||
const pending = deferred<CreateResult>()
|
||||
createCheckoutSession.mockReturnValue(pending.promise)
|
||||
|
||||
const first = controller.start('pro')
|
||||
const duplicate = controller.start('pro_plus')
|
||||
|
||||
expect(await duplicate).toBe(false)
|
||||
expect(createCheckoutSession).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(checkoutSuccess)
|
||||
expect(await first).toBe(true)
|
||||
expect(openExternal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('invalidates a pending checkout when the user closes the modal', async () => {
|
||||
const pending = deferred<CreateResult>()
|
||||
createCheckoutSession.mockReturnValue(pending.promise)
|
||||
|
||||
const start = controller.start('pro')
|
||||
controller.cancel()
|
||||
pending.resolve(checkoutSuccess)
|
||||
|
||||
expect(await start).toBe(false)
|
||||
expect(openExternal).not.toHaveBeenCalled()
|
||||
expect(controller.getState()).toEqual({ phase: 'idle', error: null, verifiedTier: null })
|
||||
})
|
||||
|
||||
it('does not treat an opened or cancelled provider page as payment success', async () => {
|
||||
await controller.start('pro')
|
||||
getSubscriptionStatus.mockResolvedValue({
|
||||
success: true,
|
||||
data: { tier: 'free', valid: false, expiresAt: null }
|
||||
})
|
||||
|
||||
const tier = await controller.verify()
|
||||
|
||||
expect(tier).toBeNull()
|
||||
expect(controller.getState()).toMatchObject({ phase: 'awaiting', error: 'not-confirmed' })
|
||||
})
|
||||
|
||||
it('fails closed when subscription readback returns an IPC error', async () => {
|
||||
await controller.start('pro')
|
||||
getSubscriptionStatus.mockResolvedValue(ipcFailure)
|
||||
|
||||
const tier = await controller.verify()
|
||||
|
||||
expect(tier).toBeNull()
|
||||
expect(controller.getState()).toMatchObject({
|
||||
phase: 'awaiting',
|
||||
error: 'verification-failed'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when subscription readback throws a network error', async () => {
|
||||
await controller.start('pro')
|
||||
getSubscriptionStatus.mockRejectedValue(new Error('private subscription details'))
|
||||
|
||||
const tier = await controller.verify()
|
||||
|
||||
expect(tier).toBeNull()
|
||||
expect(controller.getState()).toMatchObject({ phase: 'awaiting', error: 'network-failed' })
|
||||
})
|
||||
|
||||
it('rejects an arbitrary paid tier even when a forged response claims it is valid', async () => {
|
||||
await controller.start('pro')
|
||||
getSubscriptionStatus.mockResolvedValue({
|
||||
success: true,
|
||||
data: { tier: 'enterprise', valid: true, expiresAt: null }
|
||||
})
|
||||
|
||||
const tier = await controller.verify()
|
||||
|
||||
expect(tier).toBeNull()
|
||||
expect(controller.getState()).toMatchObject({
|
||||
phase: 'awaiting',
|
||||
error: 'invalid-entitlement'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns and displays only the server-read subscription tier', async () => {
|
||||
await controller.start('pro')
|
||||
|
||||
const tier = await controller.verify()
|
||||
|
||||
expect(tier).toBe('pro_plus')
|
||||
expect(controller.getState()).toEqual({
|
||||
phase: 'complete',
|
||||
error: null,
|
||||
verifiedTier: 'pro_plus'
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces duplicate subscription verification clicks', async () => {
|
||||
await controller.start('pro')
|
||||
const pending = deferred<StatusResult>()
|
||||
getSubscriptionStatus.mockReturnValue(pending.promise)
|
||||
|
||||
const first = controller.verify()
|
||||
const duplicate = controller.verify()
|
||||
|
||||
expect(await duplicate).toBeNull()
|
||||
expect(getSubscriptionStatus).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(activeSubscription)
|
||||
expect(await first).toBe('pro_plus')
|
||||
})
|
||||
|
||||
it('ignores a successful subscription response after cancellation', async () => {
|
||||
await controller.start('pro')
|
||||
const pending = deferred<StatusResult>()
|
||||
getSubscriptionStatus.mockReturnValue(pending.promise)
|
||||
|
||||
const verification = controller.verify()
|
||||
controller.cancel()
|
||||
pending.resolve(activeSubscription)
|
||||
|
||||
expect(await verification).toBeNull()
|
||||
expect(controller.getState()).toEqual({ phase: 'idle', error: null, verifiedTier: null })
|
||||
})
|
||||
|
||||
it('accepts only the trusted Stripe HTTPS checkout origin and path', () => {
|
||||
expect(isTrustedStripeCheckoutUrl(trustedUrl)).toBe(true)
|
||||
expect(isTrustedStripeCheckoutUrl('https://checkout.stripe.com/portal/cs_test_bad')).toBe(false)
|
||||
expect(
|
||||
isTrustedStripeCheckoutUrl('https://user:pass@checkout.stripe.com/c/pay/cs_test_bad')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
332
apps/desktop/tests/unit/payment-handlers.spec.ts
Normal file
332
apps/desktop/tests/unit/payment-handlers.spec.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import type { IPCResult } from '@d3ro/core/errors'
|
||||
import type { CheckoutSessionParams, CheckoutSessionResult } from '@d3ro/core/types'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const invokeFunction = vi.fn()
|
||||
const activate = vi.fn()
|
||||
const getLicenseService = vi.fn(() => ({ activate }))
|
||||
const cloud = {
|
||||
isAuthenticated: vi.fn(() => true),
|
||||
getUser: vi.fn(() => ({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' })),
|
||||
invokeFunction
|
||||
}
|
||||
return { activate, cloud, getLicenseService, invokeFunction }
|
||||
})
|
||||
|
||||
vi.mock('../../src/main/services/CloudSyncService', () => ({
|
||||
getCloudSyncService: () => mocks.cloud
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/LicenseService', () => ({
|
||||
getLicenseService: mocks.getLicenseService
|
||||
}))
|
||||
|
||||
import {
|
||||
PAYMENT_REQUEST_TIMEOUT_MS,
|
||||
registerPaymentHandlers
|
||||
} from '../../src/main/ipc/payment-handlers'
|
||||
|
||||
type CapturedHandler = (...args: unknown[]) => unknown
|
||||
|
||||
const handlers = new Map<string, CapturedHandler>()
|
||||
const validParams: CheckoutSessionParams = {
|
||||
tier: 'pro',
|
||||
provider: 'stripe'
|
||||
}
|
||||
|
||||
async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise<IPCResult<T>> {
|
||||
const handler = handlers.get(channel)
|
||||
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
|
||||
return (await handler({}, ...args)) as IPCResult<T>
|
||||
}
|
||||
|
||||
function checkoutResponse(url = 'https://checkout.stripe.com/c/pay/cs_test_safe#fragment') {
|
||||
return { data: { url }, error: null }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
handlers.clear()
|
||||
mocks.cloud.isAuthenticated.mockReturnValue(true)
|
||||
mocks.cloud.getUser.mockReturnValue({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' })
|
||||
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: CapturedHandler) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
registerPaymentHandlers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('desktop payment IPC security boundary', () => {
|
||||
it('uses the authenticated Stripe Edge checkout contract and returns only its trusted URL', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue(checkoutResponse())
|
||||
|
||||
const result = await invokeIpc<CheckoutSessionResult>(
|
||||
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
||||
validParams
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_safe#fragment',
|
||||
provider: 'stripe',
|
||||
status: 'pending'
|
||||
}
|
||||
})
|
||||
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
|
||||
const [name, body, options] = mocks.invokeFunction.mock.calls[0]
|
||||
expect(name).toBe('stripe-checkout')
|
||||
expect(body).toEqual({
|
||||
tier: 'pro',
|
||||
success_url: 'https://d3ro.chanpaca.net/billing?desktop_checkout=success',
|
||||
cancel_url: 'https://d3ro.chanpaca.net/billing?desktop_checkout=cancelled',
|
||||
idempotency_key: expect.stringMatching(/^desktop:stripe-checkout:[0-9a-f-]{36}$/)
|
||||
})
|
||||
expect(options).toMatchObject({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS })
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it.each(['toss', 'portone', 'arbitrary-provider'])(
|
||||
'rejects unsupported provider %s before network I/O',
|
||||
async (provider) => {
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, {
|
||||
...validParams,
|
||||
provider
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(mocks.invokeFunction).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['free', 'team', 'enterprise', 'arbitrary-tier'])(
|
||||
'rejects renderer-selected tier %s before network I/O',
|
||||
async (tier) => {
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, {
|
||||
...validParams,
|
||||
tier
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(mocks.invokeFunction).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'http://checkout.stripe.com/c/pay/cs_test_unsafe',
|
||||
'https://checkout.stripe.com.evil.test/c/pay/cs_test_unsafe',
|
||||
'https://checkout.stripe.com@evil.test/c/pay/cs_test_unsafe',
|
||||
'https://user:pass@checkout.stripe.com/c/pay/cs_test_unsafe',
|
||||
'https://checkout.stripe.com/portal/cs_test_unsafe'
|
||||
])('rejects an untrusted checkout URL: %s', async (url) => {
|
||||
mocks.invokeFunction.mockResolvedValue(checkoutResponse(url))
|
||||
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed on provider errors without exposing provider details', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'sensitive Stripe provider details' }
|
||||
})
|
||||
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { message: 'Checkout service rejected the request' }
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive Stripe provider details')
|
||||
})
|
||||
|
||||
it('fails closed on network errors without converting them into checkout success', async () => {
|
||||
mocks.invokeFunction.mockRejectedValue(new Error('sensitive network details'))
|
||||
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { message: 'Checkout service is unavailable' }
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive network details')
|
||||
})
|
||||
|
||||
it('coalesces concurrent and sequential duplicate checkout requests', async () => {
|
||||
let resolveProvider: ((value: ReturnType<typeof checkoutResponse>) => void) | undefined
|
||||
mocks.invokeFunction.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveProvider = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = invokeIpc<CheckoutSessionResult>(
|
||||
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
||||
validParams
|
||||
)
|
||||
const concurrent = invokeIpc<CheckoutSessionResult>(
|
||||
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
||||
validParams
|
||||
)
|
||||
|
||||
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
|
||||
resolveProvider?.(checkoutResponse())
|
||||
const [firstResult, concurrentResult] = await Promise.all([first, concurrent])
|
||||
const sequentialResult = await invokeIpc<CheckoutSessionResult>(
|
||||
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
||||
validParams
|
||||
)
|
||||
|
||||
expect(firstResult).toEqual(concurrentResult)
|
||||
expect(sequentialResult).toEqual(firstResult)
|
||||
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the server idempotency key when a failed checkout is retried', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'provider unavailable' }
|
||||
})
|
||||
|
||||
const first = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
const retry = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
|
||||
expect(first.success).toBe(false)
|
||||
expect(retry.success).toBe(false)
|
||||
expect(mocks.invokeFunction).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.invokeFunction.mock.calls[0][1].idempotency_key).toBe(
|
||||
mocks.invokeFunction.mock.calls[1][1].idempotency_key
|
||||
)
|
||||
})
|
||||
|
||||
it('aborts and fails closed when checkout exceeds the deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.invokeFunction.mockReturnValue(new Promise(() => undefined))
|
||||
|
||||
const pending = invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
await vi.advanceTimersByTimeAsync(PAYMENT_REQUEST_TIMEOUT_MS)
|
||||
const result = await pending
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { message: 'Payment server request timed out' }
|
||||
})
|
||||
const options = mocks.invokeFunction.mock.calls[0][2]
|
||||
expect(options.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('uses server subscription readback and never mutates the local license', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue({
|
||||
data: {
|
||||
tier: 'pro_plus',
|
||||
status: 'active',
|
||||
current_period_end: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
error: null
|
||||
})
|
||||
|
||||
const result = await invokeIpc<{ success: boolean; activeTier: string }>(
|
||||
IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT,
|
||||
{ tier: 'enterprise' }
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { success: true, activeTier: 'pro_plus' }
|
||||
})
|
||||
expect(mocks.invokeFunction).toHaveBeenCalledWith(
|
||||
'payple-manage',
|
||||
{ action: 'info' },
|
||||
expect.objectContaining({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS })
|
||||
)
|
||||
expect(mocks.getLicenseService).not.toHaveBeenCalled()
|
||||
expect(mocks.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not grant an arbitrary renderer tier when the server says free', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue({
|
||||
data: { tier: 'free', status: 'active', current_period_end: null },
|
||||
error: null
|
||||
})
|
||||
|
||||
const result = await invokeIpc<{ success: boolean; activeTier: string }>(
|
||||
IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT,
|
||||
{ tier: 'enterprise' }
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { success: false, activeTier: 'free' }
|
||||
})
|
||||
expect(mocks.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves prepaid cancellation until expiry and fails closed after expiry', async () => {
|
||||
mocks.invokeFunction
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
tier: 'pro',
|
||||
status: 'canceled',
|
||||
current_period_end: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
error: null
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
tier: 'pro',
|
||||
status: 'canceled',
|
||||
current_period_end: '2000-01-01T00:00:00.000Z'
|
||||
},
|
||||
error: null
|
||||
})
|
||||
|
||||
const current = await invokeIpc<{
|
||||
tier: string
|
||||
valid: boolean
|
||||
expiresAt: number | null
|
||||
}>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
|
||||
const expired = await invokeIpc<{
|
||||
tier: string
|
||||
valid: boolean
|
||||
expiresAt: number | null
|
||||
}>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
|
||||
|
||||
expect(current).toMatchObject({ success: true, data: { tier: 'pro', valid: true } })
|
||||
expect(expired).toMatchObject({ success: true, data: { tier: 'free', valid: false } })
|
||||
})
|
||||
|
||||
it('rejects malformed or arbitrary server entitlement data', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue({
|
||||
data: {
|
||||
tier: 'enterprise',
|
||||
status: 'active',
|
||||
current_period_end: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
error: null
|
||||
})
|
||||
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { message: 'Subscription status is unavailable' }
|
||||
})
|
||||
})
|
||||
|
||||
it('requires an authenticated user before invoking payment functions', async () => {
|
||||
mocks.cloud.isAuthenticated.mockReturnValue(false)
|
||||
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(mocks.invokeFunction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue