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

324 lines
11 KiB
TypeScript

import {
googlePlayRestoreAvailabilityError,
googlePlayAccountHash,
isEntitlementForRestoredProducts,
isVerifiedActivePurchase,
restoreGooglePlayPurchaseSet,
type IapVerifyResult,
} from '../src/lib/billing-context'
import type { EntitlementSnapshot } from '../src/lib/entitlement-context'
import type { Purchase } from 'react-native-iap'
import { normalizeMobileRuntimeConfig } from '../src/lib/native-config'
const PURCHASE_ID = '11111111-1111-4111-8111-111111111111'
function googlePurchase(
productId = 'd3ro_voice_pro_monthly',
token = `play_${'a'.repeat(32)}`,
overrides: Partial<Purchase> = {},
): Purchase {
return {
id: `order-${token}`,
productId,
purchaseToken: token,
purchaseState: 'purchased',
store: 'google',
transactionDate: Date.parse('2026-08-21T00:00:00.000Z'),
quantity: 1,
isAutoRenewing: true,
packageNameAndroid: 'com.d3ro.voice',
...overrides,
} as Purchase
}
function verification(
productId = 'd3ro_voice_pro_monthly',
overrides: {
state?: string
entitled?: boolean
acknowledged?: boolean
serverAcknowledged?: boolean
} = {},
): IapVerifyResult {
const acknowledged = overrides.acknowledged ?? true
return {
purchase: { purchase_id: PURCHASE_ID, acknowledged },
verification: {
product_id: productId,
purchase_state: overrides.state ?? 'purchased',
entitled: overrides.entitled ?? true,
acknowledged,
},
finish_transaction: false,
server_acknowledged: overrides.serverAcknowledged ?? acknowledged,
}
}
function entitlement(
productId: 'd3ro_voice_pro_monthly' | 'd3ro_voice_pro_plus_monthly' = 'd3ro_voice_pro_monthly',
overrides: Partial<EntitlementSnapshot> = {},
): EntitlementSnapshot {
const tier = productId === 'd3ro_voice_pro_plus_monthly' ? 'pro_plus' : 'pro'
return {
tier,
status: 'active',
provider: 'google_play',
paymentProvider: 'google_play',
currentPeriodStart: '2026-08-21T00:00:00.000Z',
currentPeriodEnd: '2026-09-21T00:00:00.000Z',
cancelAt: null,
autoRenewing: true,
storeProductId: productId,
overageCredits: 0,
usageToday: {},
purchases: [{
id: PURCHASE_ID,
platform: 'google_play',
productId,
state: 'purchased',
purchaseAt: '2026-08-21T00:00:00.000Z',
expiresAt: '2026-09-21T00:00:00.000Z',
autoRenewing: true,
verifiedAt: '2026-08-21T00:00:01.000Z',
}],
adFree: true,
refreshedAt: '2026-08-21T00:00:02.000Z',
...overrides,
}
}
describe('mobile monetization identity and native configuration', () => {
test('matches the server Google Play account binding hash', () => {
expect(googlePlayAccountHash('00000000-0000-4000-8000-000000000001')).toBe(
'a648437059eb4bc9c85c416a34e40419f7dd1e45c985aa701a23f5d86cd76678',
)
})
test('accepts canonical debug ad units', () => {
expect(normalizeMobileRuntimeConfig({
debug: true,
packageName: 'com.d3ro.voice',
adMobBannerUnitId: 'ca-app-pub-3940256099942544/6300978111',
adMobRewardedUnitId: 'ca-app-pub-3940256099942544/5224354917',
playStoreInstalled: true,
installedFromPlayStore: false,
installationId: '00000000-0000-4000-8000-000000000001',
deviceName: 'Pixel Test',
osVersion: 'Android 14',
appVersion: '1.0.0',
})).toEqual(expect.objectContaining({ debug: true, packageName: 'com.d3ro.voice' }))
})
test('rejects test ad units in production', () => {
expect(() => normalizeMobileRuntimeConfig({
debug: false,
packageName: 'com.d3ro.voice',
adMobBannerUnitId: 'ca-app-pub-3940256099942544/6300978111',
adMobRewardedUnitId: 'ca-app-pub-1234567890123456/1111111111',
playStoreInstalled: true,
installedFromPlayStore: true,
installationId: '00000000-0000-4000-8000-000000000001',
deviceName: 'Pixel Test',
osVersion: 'Android 14',
appVersion: '1.0.0',
})).toThrow('production_ad_unit_uses_test_id')
})
test('rejects a mismatched Android package', () => {
expect(() => normalizeMobileRuntimeConfig({
debug: true,
packageName: 'com.example.wrong',
adMobBannerUnitId: 'ca-app-pub-3940256099942544/6300978111',
adMobRewardedUnitId: 'ca-app-pub-3940256099942544/5224354917',
playStoreInstalled: true,
installedFromPlayStore: true,
installationId: '00000000-0000-4000-8000-000000000001',
deviceName: 'Pixel Test',
osVersion: 'Android 14',
appVersion: '1.0.0',
})).toThrow('mobile_native_config_invalid')
})
test('accepts restoration only after the server verifies an active acknowledged purchase', () => {
expect(isVerifiedActivePurchase({
verification: {
product_id: 'd3ro_voice_pro_monthly',
purchase_state: 'purchased',
entitled: true,
acknowledged: true,
},
server_acknowledged: true,
}, 'd3ro_voice_pro_monthly')).toBe(true)
})
test('accepts a cancelled but still entitled and acknowledged subscription', () => {
expect(isVerifiedActivePurchase({
verification: {
product_id: 'd3ro_voice_pro_monthly',
purchase_state: 'cancelled',
entitled: true,
acknowledged: true,
},
server_acknowledged: true,
}, 'd3ro_voice_pro_monthly')).toBe(true)
})
test.each([
['missing verification', { server_acknowledged: true }],
['pending', {
verification: {
product_id: 'd3ro_voice_pro_monthly',
purchase_state: 'pending',
entitled: false,
acknowledged: false,
},
server_acknowledged: false,
}],
['wrong product', {
verification: {
product_id: 'd3ro_voice_pro_plus_monthly',
purchase_state: 'purchased',
entitled: true,
acknowledged: true,
},
server_acknowledged: true,
}],
['unacknowledged', {
verification: {
product_id: 'd3ro_voice_pro_monthly',
purchase_state: 'purchased',
entitled: true,
acknowledged: false,
},
server_acknowledged: false,
}],
])('rejects restoration for %s', (_label, result) => {
expect(isVerifiedActivePurchase(result, 'd3ro_voice_pro_monthly')).toBe(false)
})
})
describe('Google Play restore boundary', () => {
test.each([
[{ installedFromPlayStore: false, connected: true, platform: 'android', userId: 'user' }, 'play_store_install_required'],
[{ installedFromPlayStore: true, connected: false, platform: 'android', userId: 'user' }, 'store_connection_failed'],
[{ installedFromPlayStore: true, connected: true, platform: 'ios', userId: 'user' }, 'restore_not_supported'],
[{ installedFromPlayStore: true, connected: true, platform: 'android', userId: null }, 'restore_not_supported'],
])('fails explicitly when the store boundary is unavailable', (input, expected) => {
expect(googlePlayRestoreAvailabilityError(input)).toBe(expected)
})
test('does not report a restore when the store has no supported purchases', async () => {
const verifyPurchase = jest.fn()
const refreshEntitlement = jest.fn()
await expect(restoreGooglePlayPurchaseSet([], verifyPurchase, refreshEntitlement))
.rejects.toThrow('no_restorable_purchases')
await expect(restoreGooglePlayPurchaseSet([
googlePurchase('legacy_unknown_product'),
], verifyPurchase, refreshEntitlement)).rejects.toThrow('no_restorable_purchases')
expect(verifyPurchase).not.toHaveBeenCalled()
expect(refreshEntitlement).not.toHaveBeenCalled()
})
test('deduplicates store rows and succeeds only after the refreshed matching tier is visible', async () => {
const purchase = googlePurchase()
const verifyPurchase = jest.fn(async () => verification())
const refreshed = entitlement()
const refreshEntitlement = jest.fn(async () => refreshed)
await expect(restoreGooglePlayPurchaseSet(
[purchase, { ...purchase }],
verifyPurchase,
refreshEntitlement,
)).resolves.toBe(refreshed)
expect(verifyPurchase).toHaveBeenCalledTimes(1)
expect(verifyPurchase).toHaveBeenCalledWith(purchase)
expect(refreshEntitlement).toHaveBeenCalledTimes(1)
expect(isEntitlementForRestoredProducts(
refreshed,
new Set(['d3ro_voice_pro_monthly']),
)).toBe(true)
})
test('fails the whole restore when one supported purchase cannot prove ownership', async () => {
const purchases = [
googlePurchase('d3ro_voice_pro_monthly', `play_${'a'.repeat(32)}`),
googlePurchase('d3ro_voice_pro_plus_monthly', `play_${'b'.repeat(32)}`),
]
const verifyPurchase = jest.fn(async (purchase: Purchase) => {
if (purchase.productId === 'd3ro_voice_pro_plus_monthly') {
throw new Error('purchase_account_mismatch')
}
return verification(purchase.productId)
})
const refreshEntitlement = jest.fn()
await expect(restoreGooglePlayPurchaseSet(
purchases,
verifyPurchase,
refreshEntitlement,
)).rejects.toThrow('purchase_account_mismatch')
expect(verifyPurchase).toHaveBeenCalledTimes(2)
expect(refreshEntitlement).not.toHaveBeenCalled()
})
test('verifies pending purchases but never promotes them to restored', async () => {
const verifyPurchase = jest.fn(async () => verification(
'd3ro_voice_pro_monthly',
{ state: 'pending', entitled: false, acknowledged: false, serverAcknowledged: false },
))
const refreshEntitlement = jest.fn()
await expect(restoreGooglePlayPurchaseSet(
[googlePurchase('d3ro_voice_pro_monthly', undefined, { purchaseState: 'pending' })],
verifyPurchase,
refreshEntitlement,
)).rejects.toThrow('no_active_subscription_to_restore')
expect(verifyPurchase).toHaveBeenCalledTimes(1)
expect(refreshEntitlement).not.toHaveBeenCalled()
})
test('rejects an entitled purchase unless server acknowledgement and persistence agree', async () => {
const refreshEntitlement = jest.fn()
await expect(restoreGooglePlayPurchaseSet(
[googlePurchase()],
async () => verification('d3ro_voice_pro_monthly', {
acknowledged: false,
serverAcknowledged: false,
}),
refreshEntitlement,
)).rejects.toThrow('restore_acknowledgement_missing')
expect(refreshEntitlement).not.toHaveBeenCalled()
})
test('keeps transaction finishing server-owned instead of accepting a client finish request', async () => {
const result = verification()
result.finish_transaction = true
await expect(restoreGooglePlayPurchaseSet(
[googlePurchase()],
async () => result,
jest.fn(),
)).rejects.toThrow('restore_verification_response_invalid')
})
test('rejects success when entitlement refresh does not expose the verified product tier', async () => {
await expect(restoreGooglePlayPurchaseSet(
[googlePurchase()],
async () => verification(),
async () => entitlement('d3ro_voice_pro_plus_monthly'),
)).rejects.toThrow('restored_entitlement_mismatch')
})
test.each([
[googlePurchase('d3ro_voice_pro_monthly', 'short'), 'restore_purchase_payload_invalid'],
[googlePurchase('d3ro_voice_pro_monthly', undefined, { store: 'amazon' }), 'restore_purchase_source_invalid'],
[googlePurchase('d3ro_voice_pro_monthly', undefined, { packageNameAndroid: 'com.attacker.app' }), 'restore_purchase_package_mismatch'],
])('rejects malformed or foreign supported store rows', async (purchase, expected) => {
await expect(restoreGooglePlayPurchaseSet(
[purchase],
jest.fn(),
jest.fn(),
)).rejects.toThrow(expected)
})
})