174 lines
5.7 KiB
TypeScript
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()
|
|
})
|
|
})
|