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
324
apps/mobile-rn/__tests__/billing.test.ts
Normal file
324
apps/mobile-rn/__tests__/billing.test.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue