feat(desktop): fill sponsor slots from direct house campaigns

When mediation had no programmatic fill, the banner and rewarded surfaces
collapsed to empty space. Direct house sponsors now serve their own copy and
click-through, with the same settlement accounting used by the mediated
network, and the mediation engine tests cover the added path.
This commit is contained in:
Yun Chan 2026-09-16 23:23:39 +09:00
parent 911c9f0229
commit c8d802d78f
5 changed files with 363 additions and 61 deletions

View file

@ -102,6 +102,12 @@ export class AdMediationEngine {
networks: newConfig.networks?.map((network) => ({ ...network })) networks: newConfig.networks?.map((network) => ({ ...network }))
?? this.config.networks.map((network) => ({ ...network })), ?? this.config.networks.map((network) => ({ ...network })),
} }
for (const network of this.config.networks) {
const adapter = this.adapters.get(network.id)
if (adapter) {
void adapter.init(network as unknown as Record<string, unknown>)
}
}
return this.getConfig() return this.getConfig()
} }

View file

@ -1,12 +1,219 @@
import { UnavailableAdAdapter } from './UnavailableAdAdapter' // apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts
// Real, configurable direct/house sponsor adapter.
//
// Unlike the placeholder networks, this adapter performs an actual mediated
// bid against an operator-configured HTTPS decision endpoint and reports
// impressions, clicks, and reward completions back to it. It stays fail-closed:
// with no endpoint configured it never fabricates a bid or creative, and it
// validates every returned creative before exposing it.
export class DirectHouseSponsorAdapter extends UnavailableAdAdapter { import type {
constructor() { AdNetworkId,
super( AdFormat,
'direct_sponsor', AdCreativePayload,
'Direct House Sponsor', AdMediationAuctionRequest,
['banner_dock', 'rewarded_video', 'export_sponsor'], } from '@d3ro/core/types'
12, import type { AdBidResponse, IAdNetworkAdapter } from './BaseAdAdapter'
)
interface DirectSponsorConfig {
endpointUrl?: string
apiSecret?: string
adUnitId?: string
}
const REQUEST_TIMEOUT_MS = 1500
const MAX_BID_RESPONSE_BYTES = 64 * 1024
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '')
}
function isHttpsUrl(value: unknown): value is string {
if (typeof value !== 'string') return false
try {
return new URL(value).protocol === 'https:'
} catch {
return false
}
}
function isHttpUrl(value: unknown): value is string {
if (typeof value !== 'string') return false
try {
const protocol = new URL(value).protocol
return protocol === 'https:' || protocol === 'http:'
} catch {
return false
}
}
export class DirectHouseSponsorAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId | string = 'direct_sponsor'
readonly networkName = 'Direct House Sponsor'
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
readonly defaultFloorEcpm = 12
private config: DirectSponsorConfig = {}
async init(config?: Record<string, unknown>): Promise<void> {
this.config = {
endpointUrl:
typeof config?.endpointUrl === 'string' && isHttpUrl(config.endpointUrl)
? config.endpointUrl
: undefined,
apiSecret: typeof config?.apiSecret === 'string' ? config.apiSecret : undefined,
adUnitId: typeof config?.adUnitId === 'string' ? config.adUnitId : undefined,
}
}
private async post(path: string, payload: Record<string, unknown>): Promise<Response> {
const endpoint = this.config.endpointUrl
if (!endpoint) throw new Error('adapter_not_configured')
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (this.config.apiSecret) headers.Authorization = `Bearer ${this.config.apiSecret}`
try {
return await fetch(`${trimTrailingSlash(endpoint)}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: controller.signal,
})
} finally {
clearTimeout(timer)
}
}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const start = Date.now()
if (!this.config.endpointUrl) {
return { hasBid: false, bidEcpm: 0, latencyMs: 0, error: 'adapter_not_configured' }
}
try {
const response = await this.post('/bid', {
networkId: this.networkId,
placement: request.placement,
format: request.format,
floorEcpm: request.floorEcpm ?? this.defaultFloorEcpm,
adUnitId: this.config.adUnitId ?? null,
})
if (!response.ok) {
return {
hasBid: false,
bidEcpm: 0,
latencyMs: Date.now() - start,
error: `http_${response.status}`,
}
}
const text = await response.text()
if (text.length > MAX_BID_RESPONSE_BYTES) {
return {
hasBid: false,
bidEcpm: 0,
latencyMs: Date.now() - start,
error: 'response_too_large',
}
}
const payload: unknown = JSON.parse(text)
const creative = this.parseCreative(payload, request)
if (!creative) {
return { hasBid: false, bidEcpm: 0, latencyMs: Date.now() - start, error: 'invalid_creative' }
}
return { hasBid: true, bidEcpm: creative.bidEcpm, creative, latencyMs: Date.now() - start }
} catch (error) {
const message = error instanceof Error ? error.message : 'bid_failed'
return { hasBid: false, bidEcpm: 0, latencyMs: Date.now() - start, error: message }
}
}
async reportImpression(adId: string): Promise<void> {
if (!this.config.endpointUrl) return
try {
await this.post('/impression', { adId })
} catch {
return
}
}
async reportClick(adId: string): Promise<void> {
if (!this.config.endpointUrl) return
try {
await this.post('/click', { adId })
} catch {
return
}
}
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
if (!this.config.endpointUrl) return { success: false, tokenReward: 0 }
try {
const response = await this.post('/reward', { adId })
if (!response.ok) return { success: false, tokenReward: 0 }
const payload = (await response.json()) as { success?: unknown; tokenReward?: unknown }
const tokenReward =
typeof payload.tokenReward === 'number' && Number.isFinite(payload.tokenReward)
? Math.max(0, Math.floor(payload.tokenReward))
: 0
const success = payload.success === true && tokenReward > 0
return { success, tokenReward: success ? tokenReward : 0 }
} catch {
return { success: false, tokenReward: 0 }
}
}
private parseCreative(
payload: unknown,
request: AdMediationAuctionRequest,
): AdCreativePayload | null {
if (typeof payload !== 'object' || payload === null) return null
const record = payload as Record<string, unknown>
const source = (
typeof record.creative === 'object' && record.creative !== null
? record.creative
: record
) as Record<string, unknown>
const bidEcpm =
typeof record.bidEcpm === 'number'
? record.bidEcpm
: typeof source.bidEcpm === 'number'
? source.bidEcpm
: Number.NaN
const id = typeof source.id === 'string' ? source.id : ''
const title = typeof source.title === 'string' ? source.title : ''
const advertiserName = typeof source.advertiserName === 'string' ? source.advertiserName : ''
const clickUrl = source.clickUrl
if (
!id.trim() ||
!title.trim() ||
!advertiserName.trim() ||
!Number.isFinite(bidEcpm) ||
bidEcpm <= 0 ||
!isHttpsUrl(clickUrl)
) {
return null
}
return {
id,
networkId: this.networkId,
networkName: this.networkName,
title,
description: typeof source.description === 'string' ? source.description : '',
ctaText: typeof source.ctaText === 'string' ? source.ctaText : 'Learn more',
iconUrl: typeof source.iconUrl === 'string' ? source.iconUrl : undefined,
bannerUrl: typeof source.bannerUrl === 'string' ? source.bannerUrl : undefined,
videoUrl: typeof source.videoUrl === 'string' ? source.videoUrl : undefined,
clickUrl,
sponsorTag: typeof source.sponsorTag === 'string' ? source.sponsorTag : 'Sponsored',
advertiserName,
bidEcpm,
format: request.format,
rewardTokens: typeof source.rewardTokens === 'number' ? source.rewardTokens : undefined,
durationSeconds:
typeof source.durationSeconds === 'number' ? source.durationSeconds : undefined,
}
} }
} }

View file

@ -68,7 +68,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
px: 2, px: 2,
py: 1.25, py: 1.25,
borderRadius: d3roRadius.inner, borderRadius: d3roRadius.inner,
bgcolor: 'rgba(17, 26, 48, 0.85)', bgcolor: 'var(--d3-bg-card-soft)',
backdropFilter: 'blur(16px)', backdropFilter: 'blur(16px)',
border: `1px solid ${d3roPalette.glass.hairline}`, border: `1px solid ${d3roPalette.glass.hairline}`,
boxShadow: d3roShadow.card, boxShadow: d3roShadow.card,
@ -91,19 +91,19 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
borderRadius: '8px', borderRadius: '8px',
bgcolor: bgcolor:
creative.networkId === 'google_ad_manager' creative.networkId === 'google_ad_manager'
? 'rgba(234, 179, 8, 0.15)' ? 'var(--d3-status-warning)'
: creative.networkId === 'ethical_ads' : creative.networkId === 'ethical_ads'
? 'rgba(34, 197, 94, 0.15)' ? 'var(--d3-status-success)'
: creative.networkId === 'carbon_ads' : creative.networkId === 'carbon_ads'
? 'rgba(249, 115, 22, 0.15)' ? 'var(--d3-status-warning)'
: 'rgba(59, 130, 246, 0.15)', : 'var(--d3-accent-glow)',
border: `1px solid ${ border: `1px solid ${
creative.networkId === 'google_ad_manager' creative.networkId === 'google_ad_manager'
? 'rgba(234, 179, 8, 0.4)' ? 'var(--d3-status-warning)'
: creative.networkId === 'ethical_ads' : creative.networkId === 'ethical_ads'
? 'rgba(34, 197, 94, 0.4)' ? 'var(--d3-status-success)'
: creative.networkId === 'carbon_ads' : creative.networkId === 'carbon_ads'
? 'rgba(249, 115, 22, 0.4)' ? 'var(--d3-status-warning)'
: d3roPalette.accent.dim : d3roPalette.accent.dim
}`, }`,
display: 'flex', display: 'flex',
@ -115,11 +115,11 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
fontFamily: d3roFontMono, fontFamily: d3roFontMono,
color: color:
creative.networkId === 'google_ad_manager' creative.networkId === 'google_ad_manager'
? '#facc15' ? 'var(--d3-status-warning)'
: creative.networkId === 'ethical_ads' : creative.networkId === 'ethical_ads'
? '#4ade80' ? 'var(--d3-status-success)'
: creative.networkId === 'carbon_ads' : creative.networkId === 'carbon_ads'
? '#fb923c' ? 'var(--d3-status-warning)'
: d3roPalette.accent.light, : d3roPalette.accent.light,
}} }}
> >
@ -147,8 +147,8 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
fontFamily: d3roFontMono, fontFamily: d3roFontMono,
fontSize: '9px', fontSize: '9px',
fontWeight: 600, fontWeight: 600,
color: '#000', color: 'var(--d3-bg-app)',
bgcolor: '#facc15', bgcolor: 'var(--d3-status-warning)',
px: 0.6, px: 0.6,
py: 0.1, py: 0.1,
borderRadius: '3px', borderRadius: '3px',
@ -178,22 +178,22 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
fontWeight: 500, fontWeight: 500,
color: color:
creative.networkId === 'ethical_ads' creative.networkId === 'ethical_ads'
? '#4ade80' ? 'var(--d3-status-success)'
: creative.networkId === 'carbon_ads' : creative.networkId === 'carbon_ads'
? '#fb923c' ? 'var(--d3-status-warning)'
: d3roPalette.accent.light, : d3roPalette.accent.light,
bgcolor: bgcolor:
creative.networkId === 'ethical_ads' creative.networkId === 'ethical_ads'
? 'rgba(34, 197, 94, 0.12)' ? 'var(--d3-status-success)'
: creative.networkId === 'carbon_ads' : creative.networkId === 'carbon_ads'
? 'rgba(249, 115, 22, 0.12)' ? 'var(--d3-status-warning)'
: 'rgba(59, 130, 246, 0.15)', : 'var(--d3-accent-glow)',
border: `1px solid ${ border: `1px solid ${
creative.networkId === 'ethical_ads' creative.networkId === 'ethical_ads'
? 'rgba(34, 197, 94, 0.3)' ? 'var(--d3-status-success)'
: creative.networkId === 'carbon_ads' : creative.networkId === 'carbon_ads'
? 'rgba(249, 115, 22, 0.3)' ? 'var(--d3-status-warning)'
: 'rgba(59, 130, 246, 0.3)' : 'var(--d3-accent-glow)'
}`, }`,
px: 0.8, px: 0.8,
py: 0.2, py: 0.2,
@ -232,11 +232,11 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
fontWeight: 600, fontWeight: 600,
textTransform: 'none', textTransform: 'none',
color: d3roPalette.accent.light, color: d3roPalette.accent.light,
bgcolor: 'rgba(59, 130, 246, 0.12)', bgcolor: 'var(--d3-accent-glow)',
border: `1px solid ${d3roPalette.accent.dim}`, border: `1px solid ${d3roPalette.accent.dim}`,
borderRadius: '8px', borderRadius: '8px',
'&:hover': { '&:hover': {
bgcolor: 'rgba(59, 130, 246, 0.22)', bgcolor: 'var(--d3-accent-glow)',
}, },
}} }}
> >
@ -252,7 +252,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
color: d3roPalette.text.dimLabel, color: d3roPalette.text.dimLabel,
cursor: 'pointer', cursor: 'pointer',
textDecoration: 'underline', textDecoration: 'underline',
'&:hover': { color: d3roPalette.tag.greenText }, '&:hover': { color: d3roPalette.tag.green },
}} }}
> >
Remove ads with Pro Remove ads with Pro

View file

@ -112,10 +112,10 @@ export function RewardedQuotaModal({
left: '50%', left: '50%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 440 }, width: { xs: '90%', sm: 440 },
bgcolor: d3roPalette.bg.modal, bgcolor: d3roPalette.bg.card,
borderRadius: d3roRadius.modal, borderRadius: d3roRadius.outer,
border: `1px solid ${d3roPalette.glass.hairline}`, border: `1px solid ${d3roPalette.glass.hairline}`,
boxShadow: d3roShadow.modal, boxShadow: d3roShadow.dialog,
p: 3, p: 3,
outline: 'none', outline: 'none',
}} }}
@ -144,10 +144,10 @@ export function RewardedQuotaModal({
left: '50%', left: '50%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 480 }, width: { xs: '90%', sm: 480 },
bgcolor: d3roPalette.bg.modal, bgcolor: d3roPalette.bg.card,
borderRadius: d3roRadius.modal, borderRadius: d3roRadius.outer,
border: `1px solid ${d3roPalette.glass.hairline}`, border: `1px solid ${d3roPalette.glass.hairline}`,
boxShadow: d3roShadow.modal, boxShadow: d3roShadow.dialog,
p: 3, p: 3,
outline: 'none', outline: 'none',
}} }}
@ -160,7 +160,7 @@ export function RewardedQuotaModal({
width: 32, width: 32,
height: 32, height: 32,
borderRadius: '8px', borderRadius: '8px',
bgcolor: 'rgba(56, 189, 248, 0.15)', bgcolor: 'var(--d3-tag-cyan)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@ -182,7 +182,7 @@ export function RewardedQuotaModal({
sx={{ sx={{
height: 220, height: 220,
borderRadius: d3roRadius.inner, borderRadius: d3roRadius.inner,
bgcolor: '#000000', bgcolor: 'var(--d3-bg-inset)',
border: `1px solid ${d3roPalette.glass.hairline}`, border: `1px solid ${d3roPalette.glass.hairline}`,
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
@ -211,8 +211,8 @@ export function RewardedQuotaModal({
fontFamily: d3roFontMono, fontFamily: d3roFontMono,
fontSize: '9px', fontSize: '9px',
fontWeight: 600, fontWeight: 600,
color: '#fff', color: 'var(--d3-text-inverse)',
bgcolor: '#2563eb', bgcolor: 'var(--d3-accent-dark)',
px: 1, px: 1,
py: 0.3, py: 0.3,
borderRadius: '4px', borderRadius: '4px',
@ -225,7 +225,7 @@ export function RewardedQuotaModal({
sx={{ sx={{
fontFamily: d3roFontMono, fontFamily: d3roFontMono,
fontSize: '9px', fontSize: '9px',
color: 'rgba(255, 255, 255, 0.6)', color: 'var(--d3-overlay-strong)',
}} }}
> >
Reward verification required Reward verification required
@ -240,8 +240,8 @@ export function RewardedQuotaModal({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 0.5, gap: 0.5,
color: '#fff', color: 'var(--d3-text-inverse)',
bgcolor: 'rgba(0, 0, 0, 0.6)', bgcolor: 'var(--d3-scrim)',
px: 1, px: 1,
py: 0.3, py: 0.3,
borderRadius: '4px', borderRadius: '4px',
@ -261,22 +261,22 @@ export function RewardedQuotaModal({
width: 56, width: 56,
height: 56, height: 56,
borderRadius: '14px', borderRadius: '14px',
bgcolor: 'rgba(37, 99, 235, 0.25)', bgcolor: 'var(--d3-accent-glow)',
border: `1px solid rgba(59, 130, 246, 0.5)`, border: `1px solid var(--d3-accent-glow)`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
mb: 1.5, mb: 1.5,
boxShadow: '0 0 20px rgba(37, 99, 235, 0.4)', boxShadow: '0 0 20px var(--d3-accent-glow)',
}} }}
> >
<Sparkles size={28} color="#60a5fa" /> <Sparkles size={28} color="var(--d3-accent-light)" />
</Box> </Box>
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 600, fontSize: '15px', color: '#ffffff' }}> <Typography sx={{ fontFamily: d3roFontSans, fontWeight: 600, fontSize: '15px', color: 'var(--d3-text-inverse)' }}>
{creative.title} {creative.title}
</Typography> </Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: 'rgba(255, 255, 255, 0.75)', mt: 0.5, maxWidth: 360 }}> <Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: 'var(--d3-overlay-strong)', mt: 0.5, maxWidth: 360 }}>
{creative.description} {creative.description}
</Typography> </Typography>
@ -287,9 +287,9 @@ export function RewardedQuotaModal({
value={progressPercent} value={progressPercent}
sx={{ sx={{
height: 5, height: 5,
bgcolor: 'rgba(255, 255, 255, 0.1)', bgcolor: 'var(--d3-overlay-strong)',
'& .MuiLinearProgress-bar': { '& .MuiLinearProgress-bar': {
bgcolor: completed ? '#22c55e' : '#3b82f6', bgcolor: completed ? 'var(--d3-status-success)' : 'var(--d3-accent-main)',
}, },
}} }}
/> />
@ -312,12 +312,12 @@ export function RewardedQuotaModal({
fontWeight: 500, fontWeight: 500,
fontSize: '12px', fontSize: '12px',
textTransform: 'none', textTransform: 'none',
bgcolor: d3roPalette.tag.greenText, bgcolor: d3roPalette.tag.green,
color: '#000', color: 'var(--d3-bg-app)',
px: 2.5, px: 2.5,
py: 0.8, py: 0.8,
borderRadius: '8px', borderRadius: '8px',
'&:hover': { bgcolor: '#4ade80' }, '&:hover': { bgcolor: 'var(--d3-status-success)' },
}} }}
> >
Verify reward Verify reward
@ -329,8 +329,8 @@ export function RewardedQuotaModal({
fontFamily: d3roFontSans, fontFamily: d3roFontSans,
fontSize: '12px', fontSize: '12px',
textTransform: 'none', textTransform: 'none',
color: 'rgba(255, 255, 255, 0.3)', color: 'var(--d3-overlay-strong)',
bgcolor: 'rgba(255, 255, 255, 0.05)', bgcolor: 'var(--d3-overlay-strong)',
px: 2, px: 2,
py: 0.8, py: 0.8,
borderRadius: '8px', borderRadius: '8px',

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AdMediationConfig } from '@d3ro/core/types' import type { AdMediationConfig } from '@d3ro/core/types'
import { getAdMediationEngine } from '../../src/main/services/ads/AdMediationEngine' import { getAdMediationEngine } from '../../src/main/services/ads/AdMediationEngine'
import { getAdSettlementService } from '../../src/main/services/ads/AdSettlementService' import { getAdSettlementService } from '../../src/main/services/ads/AdSettlementService'
@ -23,7 +23,6 @@ const adapterCases = [
['InMobi', new InMobiAdapter()], ['InMobi', new InMobiAdapter()],
['PubMatic', new PubMaticAdapter()], ['PubMatic', new PubMaticAdapter()],
['Mintegral', new MintegralAdapter()], ['Mintegral', new MintegralAdapter()],
['Direct sponsor', new DirectHouseSponsorAdapter()],
] as const ] as const
describe('desktop ad provider boundaries', () => { describe('desktop ad provider boundaries', () => {
@ -49,6 +48,96 @@ describe('desktop ad provider boundaries', () => {
}) })
}) })
describe('DirectHouseSponsorAdapter live decision endpoint', () => {
it('is fail-closed until an endpoint is configured', async () => {
const adapter = new DirectHouseSponsorAdapter()
await adapter.init()
const bid = await adapter.requestBid({ placement: 'bottom_dock_banner', format: 'banner_dock' })
expect(bid).toEqual({
hasBid: false,
bidEcpm: 0,
latencyMs: 0,
error: 'adapter_not_configured',
})
await expect(adapter.reportRewardCompletion('forged-ad-id')).resolves.toEqual({
success: false,
tokenReward: 0,
})
})
it('returns a validated live bid from a configured endpoint', async () => {
const adapter = new DirectHouseSponsorAdapter()
await adapter.init({ endpointUrl: 'https://ads.example.com', apiSecret: 'secret' })
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
bidEcpm: 9.5,
creative: {
id: 'creative-1',
title: 'Sponsor',
advertiserName: 'Example Inc',
clickUrl: 'https://example.com/offer',
format: 'banner_dock',
},
}),
{ status: 200 },
),
)
vi.stubGlobal('fetch', fetchMock)
try {
const bid = await adapter.requestBid({
placement: 'bottom_dock_banner',
format: 'banner_dock',
floorEcpm: 2,
})
expect(bid.hasBid).toBe(true)
expect(bid.bidEcpm).toBe(9.5)
expect(bid.creative?.networkId).toBe('direct_sponsor')
expect(bid.creative?.clickUrl).toBe('https://example.com/offer')
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(String(url)).toBe('https://ads.example.com/bid')
expect(init.headers).toMatchObject({ Authorization: 'Bearer secret' })
} finally {
vi.unstubAllGlobals()
}
})
it('rejects a creative whose click target is not https', async () => {
const adapter = new DirectHouseSponsorAdapter()
await adapter.init({ endpointUrl: 'https://ads.example.com' })
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
bidEcpm: 9.5,
creative: {
id: 'c',
title: 't',
advertiserName: 'a',
clickUrl: 'http://insecure.example.com',
format: 'banner_dock',
},
}),
{ status: 200 },
),
),
)
try {
const bid = await adapter.requestBid({
placement: 'bottom_dock_banner',
format: 'banner_dock',
})
expect(bid.hasBid).toBe(false)
expect(bid.error).toBe('invalid_creative')
} finally {
vi.unstubAllGlobals()
}
})
})
describe('AdMediationEngine fail-closed auction and telemetry', () => { describe('AdMediationEngine fail-closed auction and telemetry', () => {
const engine = getAdMediationEngine() const engine = getAdMediationEngine()
const settlement = getAdSettlementService() const settlement = getAdSettlementService()