d3ro-voice/apps/mobile-rn/__tests__/mobile-ads.test.tsx
2026-08-29 18:33:45 +09:00

174 lines
5.7 KiB
TypeScript

import React from 'react'
import ReactTestRenderer, { act } from 'react-test-renderer'
jest.mock('../src/lib/auth-context', () => ({ useAuth: jest.fn() }))
jest.mock('../src/lib/entitlement-context', () => ({ useEntitlement: jest.fn() }))
jest.mock('../src/lib/native-config', () => ({ getMobileRuntimeConfig: jest.fn() }))
jest.mock('../src/lib/supabase', () => ({ supabase: { from: jest.fn() } }))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: jest.fn(() => ({
palette: {
accent: { main: '#3366ff' },
bg: { inset: '#111111' },
border: { subtle: '#222222' },
tag: { green: '#00aa66', red: '#cc3344' },
text: {
primary: '#ffffff',
secondary: '#dddddd',
muted: '#999999',
onAccent: '#ffffff',
},
},
})),
}))
const mockedAuth = jest.requireMock('../src/lib/auth-context') as { useAuth: jest.Mock }
const mockedEntitlement = jest.requireMock('../src/lib/entitlement-context') as {
useEntitlement: jest.Mock
}
const mockedConfig = jest.requireMock('../src/lib/native-config') as {
getMobileRuntimeConfig: jest.Mock
}
// The mapper already resolves this import to our manual mock. requireActual
// keeps the same module instance that production code receives.
const mockedAds = jest.requireActual('react-native-google-mobile-ads') as {
AdsConsent: {
gatherConsent: jest.Mock
getConsentInfo: jest.Mock
}
MobileAds: jest.Mock
BannerAd: jest.Mock
useRewardedAd: jest.Mock
}
import { MobileAdsProvider, useRewardedCredits } from '../src/lib/mobile-ads-context'
import FreeTierBanner from '../src/components/FreeTierBanner'
const USER_ID = '11111111-1111-4111-8111-111111111111'
const REWARDED_TEST_ID = 'ca-app-pub-3940256099942544/5224354917'
describe('mobile rewarded ad lifecycle', () => {
beforeEach(() => {
mockedAuth.useAuth.mockReturnValue({ user: { id: USER_ID } })
mockedEntitlement.useEntitlement.mockReturnValue({
loading: false,
stale: false,
error: null,
snapshot: {
tier: 'free',
adFree: false,
overageCredits: 0,
},
refresh: jest.fn(async () => null),
})
mockedConfig.getMobileRuntimeConfig.mockReturnValue({
debug: true,
adMobBannerUnitId: 'ca-app-pub-3940256099942544/6300978111',
adMobRewardedUnitId: REWARDED_TEST_ID,
})
const consentInfo = {
canRequestAds: true,
privacyOptionsRequirementStatus: 'NOT_REQUIRED',
}
mockedAds.AdsConsent.gatherConsent.mockReset().mockResolvedValue(consentInfo)
mockedAds.AdsConsent.getConsentInfo.mockReset().mockResolvedValue(consentInfo)
mockedAds.MobileAds.mockClear()
mockedAds.BannerAd.mockClear()
mockedAds.BannerAd.mockImplementation(() => React.createElement(React.Fragment, null))
})
it('retries loading when the native hook replaces its initial null-ad callback', async () => {
const nullAdLoad = jest.fn()
const nativeAdLoad = jest.fn()
mockedAds.useRewardedAd.mockImplementation((adUnitId: string | null) => {
const [nativeAdReady, setNativeAdReady] = React.useState(false)
React.useEffect(() => {
const timer = setTimeout(() => {
setNativeAdReady(adUnitId !== null)
}, 0)
return () => clearTimeout(timer)
}, [adUnitId])
const load = React.useCallback(() => {
if (nativeAdReady) nativeAdLoad()
else nullAdLoad()
}, [nativeAdReady])
return {
error: undefined,
isClosed: false,
isEarnedReward: false,
isLoaded: false,
isShowing: false,
load,
show: jest.fn(),
}
})
function Probe(): null {
useRewardedCredits()
return null
}
let renderer: ReactTestRenderer.ReactTestRenderer
await act(async () => {
renderer = ReactTestRenderer.create(
<MobileAdsProvider><Probe /></MobileAdsProvider>,
)
await Promise.resolve()
await Promise.resolve()
})
for (let index = 0; index < 5; index += 1) {
await act(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
})
}
expect(mockedAds.AdsConsent.gatherConsent).toHaveBeenCalledTimes(1)
expect(mockedAds.useRewardedAd).toHaveBeenCalledWith(
REWARDED_TEST_ID,
expect.objectContaining({ requestNonPersonalizedAdsOnly: true }),
)
expect(nullAdLoad).toHaveBeenCalledTimes(1)
expect(nativeAdLoad).toHaveBeenCalledTimes(1)
act(() => renderer!.unmount())
})
it('remounts a banner with bounded backoff after a transient load failure', async () => {
jest.useFakeTimers()
let latestBannerProps: {
onAdFailedToLoad: () => void
onAdLoaded: () => void
unitId: string
} | null = null
mockedAds.BannerAd.mockImplementation((props) => {
latestBannerProps = props
return React.createElement(React.Fragment, null)
})
let renderer: ReactTestRenderer.ReactTestRenderer
await act(async () => {
renderer = ReactTestRenderer.create(
<MobileAdsProvider><FreeTierBanner /></MobileAdsProvider>,
)
await Promise.resolve()
await Promise.resolve()
})
expect(latestBannerProps?.unitId).toBe('ca-app-pub-3940256099942544/6300978111')
act(() => latestBannerProps?.onAdFailedToLoad())
expect(JSON.stringify(renderer!.toJSON())).toContain('AD UNAVAILABLE')
await act(async () => {
jest.advanceTimersByTime(30_000)
await Promise.resolve()
})
expect(mockedAds.BannerAd).toHaveBeenCalledTimes(2)
expect(latestBannerProps?.unitId).toBe('ca-app-pub-3940256099942544/6300978111')
act(() => latestBannerProps?.onAdLoaded())
expect(JSON.stringify(renderer!.toJSON())).not.toContain('AD UNAVAILABLE')
act(() => renderer!.unmount())
jest.useRealTimers()
})
})