@namiml/expo-nami-iap 3.4.0-dev.202605060437

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +121 -0
  2. package/android/build.gradle +46 -0
  3. package/android/gradle.properties +1 -0
  4. package/android/libs/.gitkeep +0 -0
  5. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackend.kt +427 -0
  6. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonProductMapper.kt +119 -0
  7. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonPurchasingListener.kt +35 -0
  8. package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazon.kt +46 -0
  9. package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazonReceiver.kt +48 -0
  10. package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingBackend.kt +446 -0
  11. package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingConnection.kt +104 -0
  12. package/android/src/main/AndroidManifest.xml +1 -0
  13. package/android/src/main/java/ml/nami/expo/iap/Errors.kt +94 -0
  14. package/android/src/main/java/ml/nami/expo/iap/ExpoNamiIapModule.kt +56 -0
  15. package/android/src/main/java/ml/nami/expo/iap/IapBackend.kt +72 -0
  16. package/android/src/main/java/ml/nami/expo/iap/ProductMapper.kt +191 -0
  17. package/android/src/test/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackendTest.kt +974 -0
  18. package/android/src/test/java/ml/nami/expo/iap/ErrorsTest.kt +184 -0
  19. package/android/src/test/java/ml/nami/expo/iap/ExpoNamiIapModuleTest.kt +86 -0
  20. package/android/src/test/java/ml/nami/expo/iap/PlaceholderTest.kt +8 -0
  21. package/android/src/test/java/ml/nami/expo/iap/ProductMapperTest.kt +335 -0
  22. package/android/src/test/java/ml/nami/expo/iap/google/PlayBillingBackendTest.kt +841 -0
  23. package/android/src/testAmazon/java/ml/nami/expo/iap/ExpoNamiIapModuleAmazonFlavorTest.kt +71 -0
  24. package/dist/index.cjs +56 -0
  25. package/dist/index.d.ts +62 -0
  26. package/dist/index.mjs +45 -0
  27. package/expo-module.config.json +9 -0
  28. package/ios/Errors.swift +178 -0
  29. package/ios/ExpoNamiIap.podspec +14 -0
  30. package/ios/ExpoNamiIapModule.swift +81 -0
  31. package/ios/ProductMapper.swift +130 -0
  32. package/ios/PromotionalOfferSigner.swift +78 -0
  33. package/ios/StoreKit2Helper.swift +252 -0
  34. package/ios/Tests/ErrorsTests.swift +189 -0
  35. package/ios/Tests/ProductMapperTests.swift +165 -0
  36. package/ios/Tests/Resources/Products.storekit +210 -0
  37. package/ios/Tests/StoreKit2HelperTests.swift +360 -0
  38. package/ios/TransactionObserver.swift +54 -0
  39. package/package.json +64 -0
  40. package/src/ExpoNamiIapModule.kepler.ts +200 -0
  41. package/src/ExpoNamiIapModule.ts +18 -0
  42. package/src/index.ts +63 -0
  43. package/src/types.ts +46 -0
@@ -0,0 +1,841 @@
1
+ package ml.nami.expo.iap.google
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import com.android.billingclient.api.AcknowledgePurchaseParams
6
+ import com.android.billingclient.api.BillingClient
7
+ import com.android.billingclient.api.BillingClient.BillingResponseCode
8
+ import com.android.billingclient.api.BillingClient.ProductType
9
+ import com.android.billingclient.api.BillingClientStateListener
10
+ import com.android.billingclient.api.BillingFlowParams
11
+ import com.android.billingclient.api.BillingResult
12
+ import com.android.billingclient.api.ConsumeParams
13
+ import com.android.billingclient.api.ProductDetails
14
+ import com.android.billingclient.api.ProductDetailsResult
15
+ import com.android.billingclient.api.Purchase
16
+ import com.android.billingclient.api.PurchasesResult
17
+ import com.android.billingclient.api.PurchasesUpdatedListener
18
+ import com.android.billingclient.api.QueryProductDetailsParams
19
+ import com.android.billingclient.api.QueryPurchasesParams
20
+ import io.mockk.coEvery
21
+ import io.mockk.coVerify
22
+ import io.mockk.every
23
+ import io.mockk.mockk
24
+ import io.mockk.slot
25
+ import io.mockk.verify
26
+ import kotlinx.coroutines.launch
27
+ import kotlinx.coroutines.test.advanceUntilIdle
28
+ import kotlinx.coroutines.test.runTest
29
+ import ml.nami.expo.iap.ExpoNamiIapException
30
+ import org.junit.Assert.assertEquals
31
+ import org.junit.Assert.assertNotNull
32
+ import org.junit.Assert.assertTrue
33
+ import org.junit.Assert.fail
34
+ import org.junit.Before
35
+ import org.junit.Test
36
+
37
+ class PlayBillingBackendTest {
38
+ private lateinit var backend: PlayBillingBackend
39
+ private lateinit var mockContext: Context
40
+ private lateinit var mockActivity: Activity
41
+ private lateinit var mockBillingClient: BillingClient
42
+ private lateinit var mockBuilder: BillingClient.Builder
43
+ private var capturedPurchasesListener: PurchasesUpdatedListener? = null
44
+
45
+ // ── Helpers ─────────────────────────────────────────────────────────
46
+
47
+ private fun mockPricingPhase(
48
+ priceMicros: Long,
49
+ currency: String = "USD",
50
+ billingPeriod: String = "P1M",
51
+ billingCycleCount: Int = 0,
52
+ recurrenceMode: Int = 1,
53
+ ): ProductDetails.PricingPhase {
54
+ val phase = mockk<ProductDetails.PricingPhase>()
55
+ every { phase.priceAmountMicros } returns priceMicros
56
+ every { phase.priceCurrencyCode } returns currency
57
+ every { phase.billingPeriod } returns billingPeriod
58
+ every { phase.billingCycleCount } returns billingCycleCount
59
+ every { phase.recurrenceMode } returns recurrenceMode
60
+ every { phase.formattedPrice } returns "$${priceMicros / 1_000_000.0}"
61
+ return phase
62
+ }
63
+
64
+ private fun mockPricingPhases(phases: List<ProductDetails.PricingPhase>): ProductDetails.PricingPhases {
65
+ val pp = mockk<ProductDetails.PricingPhases>()
66
+ every { pp.pricingPhaseList } returns phases
67
+ return pp
68
+ }
69
+
70
+ private fun mockSubscriptionOfferDetails(
71
+ basePlanId: String = "base",
72
+ offerId: String? = null,
73
+ phases: List<ProductDetails.PricingPhase>,
74
+ offerToken: String = "token-$basePlanId-${offerId ?: "base"}",
75
+ ): ProductDetails.SubscriptionOfferDetails {
76
+ val sod = mockk<ProductDetails.SubscriptionOfferDetails>()
77
+ every { sod.basePlanId } returns basePlanId
78
+ every { sod.offerId } returns offerId
79
+ every { sod.pricingPhases } returns mockPricingPhases(phases)
80
+ every { sod.offerTags } returns emptyList()
81
+ every { sod.offerToken } returns offerToken
82
+ return sod
83
+ }
84
+
85
+ private fun mockInAppProduct(
86
+ productId: String = "com.nami.test.consumable",
87
+ name: String = "100 Coins",
88
+ description: String = "Pack of 100 coins",
89
+ priceMicros: Long = 4_990_000L,
90
+ currency: String = "USD",
91
+ ): ProductDetails {
92
+ val otp = mockk<ProductDetails.OneTimePurchaseOfferDetails>()
93
+ every { otp.priceAmountMicros } returns priceMicros
94
+ every { otp.priceCurrencyCode } returns currency
95
+ every { otp.formattedPrice } returns "$${priceMicros / 1_000_000.0}"
96
+
97
+ val pd = mockk<ProductDetails>()
98
+ every { pd.productId } returns productId
99
+ every { pd.name } returns name
100
+ every { pd.description } returns description
101
+ every { pd.productType } returns ProductType.INAPP
102
+ every { pd.oneTimePurchaseOfferDetails } returns otp
103
+ every { pd.subscriptionOfferDetails } returns null
104
+ return pd
105
+ }
106
+
107
+ private fun mockSubscriptionProduct(
108
+ productId: String = "com.nami.test.monthly",
109
+ name: String = "Monthly Premium",
110
+ description: String = "Premium monthly subscription",
111
+ basePriceMicros: Long = 9_990_000L,
112
+ currency: String = "USD",
113
+ billingPeriod: String = "P1M",
114
+ extraOffers: List<ProductDetails.SubscriptionOfferDetails> = emptyList(),
115
+ ): ProductDetails {
116
+ val basePlan =
117
+ mockSubscriptionOfferDetails(
118
+ phases =
119
+ listOf(
120
+ mockPricingPhase(
121
+ priceMicros = basePriceMicros,
122
+ currency = currency,
123
+ billingPeriod = billingPeriod,
124
+ ),
125
+ ),
126
+ )
127
+
128
+ val pd = mockk<ProductDetails>()
129
+ every { pd.productId } returns productId
130
+ every { pd.name } returns name
131
+ every { pd.description } returns description
132
+ every { pd.productType } returns ProductType.SUBS
133
+ every { pd.oneTimePurchaseOfferDetails } returns null
134
+ every { pd.subscriptionOfferDetails } returns listOf(basePlan) + extraOffers
135
+ return pd
136
+ }
137
+
138
+ private fun billingResult(code: Int): BillingResult {
139
+ val br = mockk<BillingResult>()
140
+ every { br.responseCode } returns code
141
+ every { br.debugMessage } returns "mock"
142
+ return br
143
+ }
144
+
145
+ /**
146
+ * Configure the mock BillingClient so that startConnection immediately
147
+ * invokes onBillingSetupFinished with the given response code.
148
+ */
149
+ private fun stubConnection(responseCode: Int = BillingResponseCode.OK) {
150
+ val listenerSlot = slot<BillingClientStateListener>()
151
+ every { mockBillingClient.startConnection(capture(listenerSlot)) } answers {
152
+ listenerSlot.captured.onBillingSetupFinished(billingResult(responseCode))
153
+ }
154
+ every { mockBillingClient.isReady } returns (responseCode == BillingResponseCode.OK)
155
+ }
156
+
157
+ /**
158
+ * Stub queryProductDetails for a given product type returning the specified products.
159
+ */
160
+ private fun stubProductDetailsQuery(products: List<ProductDetails>) {
161
+ val result = mockk<ProductDetailsResult>()
162
+ every { result.billingResult } returns billingResult(BillingResponseCode.OK)
163
+ every { result.productDetailsList } returns products
164
+
165
+ coEvery { mockBillingClient.queryProductDetails(any<QueryProductDetailsParams>()) } returns result
166
+ }
167
+
168
+ /**
169
+ * Stub queryPurchasesAsync for SUBS type returning the specified purchases.
170
+ */
171
+ private fun stubPurchasesQuery(purchases: List<Purchase>) {
172
+ val subsResult = mockk<PurchasesResult>()
173
+ every { subsResult.billingResult } returns billingResult(BillingResponseCode.OK)
174
+ every { subsResult.purchasesList } returns purchases
175
+
176
+ coEvery {
177
+ mockBillingClient.queryPurchasesAsync(
178
+ match<QueryPurchasesParams> { true },
179
+ )
180
+ } returns subsResult
181
+ }
182
+
183
+ @Before
184
+ fun setUp() {
185
+ mockContext = mockk(relaxed = true)
186
+ mockActivity = mockk(relaxed = true)
187
+ mockBillingClient = mockk(relaxed = true)
188
+ mockBuilder = mockk(relaxed = true)
189
+
190
+ val listenerSlot = slot<PurchasesUpdatedListener>()
191
+ every { mockBuilder.setListener(capture(listenerSlot)) } answers {
192
+ capturedPurchasesListener = listenerSlot.captured
193
+ mockBuilder
194
+ }
195
+ every { mockBuilder.enablePendingPurchases(any()) } returns mockBuilder
196
+ every { mockBuilder.build() } returns mockBillingClient
197
+
198
+ backend = PlayBillingBackend()
199
+ }
200
+
201
+ /**
202
+ * Helper: create a mock [Purchase].
203
+ */
204
+ private fun mockPurchase(
205
+ productIds: List<String> = listOf("com.nami.test.product"),
206
+ orderId: String = "GPA.1234-5678-9012",
207
+ purchaseToken: String = "test-token",
208
+ purchaseTime: Long = 1_700_000_000_000L,
209
+ isAcknowledged: Boolean = false,
210
+ originalJson: String = """{"orderId":"$orderId"}""",
211
+ ): Purchase {
212
+ val purchase = mockk<Purchase>()
213
+ every { purchase.products } returns productIds
214
+ every { purchase.orderId } returns orderId
215
+ every { purchase.purchaseToken } returns purchaseToken
216
+ every { purchase.purchaseTime } returns purchaseTime
217
+ every { purchase.isAcknowledged } returns isAcknowledged
218
+ every { purchase.originalJson } returns originalJson
219
+ every { purchase.purchaseState } returns Purchase.PurchaseState.PURCHASED
220
+ return purchase
221
+ }
222
+
223
+ /**
224
+ * Connect the backend using a testing connection and populate products cache.
225
+ */
226
+ private suspend fun connectAndCacheProducts(products: List<ProductDetails>) {
227
+ stubConnection()
228
+ stubProductDetailsQuery(products)
229
+ val connection = PlayBillingConnection(mockContext, mockBuilder)
230
+ backend.setConnectionForTesting(connection)
231
+ connection.ensureConnected()
232
+ val skuIds = products.map { it.productId }
233
+ backend.getProducts(skuIds)
234
+ }
235
+
236
+ // ── getProducts ────────────────────────────────────────────────────
237
+
238
+ @Test
239
+ fun `getProducts issues SUBS and INAPP queries and combines results`() =
240
+ runTest {
241
+ stubConnection()
242
+
243
+ val subProduct = mockSubscriptionProduct(productId = "sub-1")
244
+ val inappProduct = mockInAppProduct(productId = "iap-1")
245
+
246
+ // Return both products from the query (the backend queries twice)
247
+ val subsResult = mockk<ProductDetailsResult>()
248
+ every { subsResult.billingResult } returns billingResult(BillingResponseCode.OK)
249
+ every { subsResult.productDetailsList } returns listOf(subProduct)
250
+
251
+ val inappResult = mockk<ProductDetailsResult>()
252
+ every { inappResult.billingResult } returns billingResult(BillingResponseCode.OK)
253
+ every { inappResult.productDetailsList } returns listOf(inappProduct)
254
+
255
+ // Match based on product type in the query params
256
+ coEvery {
257
+ mockBillingClient.queryProductDetails(
258
+ match<QueryProductDetailsParams> { true },
259
+ )
260
+ } returns subsResult andThen inappResult
261
+
262
+ val connection = PlayBillingConnection(mockContext, mockBuilder)
263
+ backend.setConnectionForTesting(connection)
264
+ connection.ensureConnected()
265
+
266
+ val products = backend.getProducts(listOf("sub-1", "iap-1"))
267
+
268
+ assertEquals(2, products.size)
269
+ val productIds = products.map { it["product_ref_id"] }
270
+ assertTrue("sub-1" in productIds)
271
+ assertTrue("iap-1" in productIds)
272
+ }
273
+
274
+ @Test
275
+ fun `getProducts preserves input order`() =
276
+ runTest {
277
+ stubConnection()
278
+
279
+ val subProduct = mockSubscriptionProduct(productId = "sub-1")
280
+ val inappProduct = mockInAppProduct(productId = "iap-1")
281
+
282
+ val subsResult = mockk<ProductDetailsResult>()
283
+ every { subsResult.billingResult } returns billingResult(BillingResponseCode.OK)
284
+ every { subsResult.productDetailsList } returns listOf(subProduct)
285
+
286
+ val inappResult = mockk<ProductDetailsResult>()
287
+ every { inappResult.billingResult } returns billingResult(BillingResponseCode.OK)
288
+ every { inappResult.productDetailsList } returns listOf(inappProduct)
289
+
290
+ coEvery {
291
+ mockBillingClient.queryProductDetails(any<QueryProductDetailsParams>())
292
+ } returns subsResult andThen inappResult
293
+
294
+ val connection = PlayBillingConnection(mockContext, mockBuilder)
295
+ backend.setConnectionForTesting(connection)
296
+ connection.ensureConnected()
297
+
298
+ // Request iap-1 first, then sub-1
299
+ val products = backend.getProducts(listOf("iap-1", "sub-1"))
300
+
301
+ assertEquals(2, products.size)
302
+ assertEquals("iap-1", products[0]["product_ref_id"])
303
+ assertEquals("sub-1", products[1]["product_ref_id"])
304
+ }
305
+
306
+ @Test
307
+ fun `getProducts when disconnected auto-retries startConnection`() =
308
+ runTest {
309
+ // First call: connection succeeds, but isReady returns false then true
310
+ val listenerSlot = slot<BillingClientStateListener>()
311
+ var connectionAttempts = 0
312
+ every { mockBillingClient.startConnection(capture(listenerSlot)) } answers {
313
+ connectionAttempts++
314
+ listenerSlot.captured.onBillingSetupFinished(
315
+ billingResult(BillingResponseCode.OK),
316
+ )
317
+ }
318
+ every { mockBillingClient.isReady } returns true
319
+
320
+ val subProduct = mockSubscriptionProduct(productId = "sub-1")
321
+ stubProductDetailsQuery(listOf(subProduct))
322
+
323
+ val connection = PlayBillingConnection(mockContext, mockBuilder)
324
+ backend.setConnectionForTesting(connection)
325
+ connection.ensureConnected()
326
+
327
+ val products = backend.getProducts(listOf("sub-1"))
328
+ assertTrue(connectionAttempts >= 1)
329
+ assertEquals(1, products.size)
330
+ }
331
+
332
+ // ── checkEligibility ───────────────────────────────────────────────
333
+
334
+ @Test
335
+ fun `checkEligibility with no prior purchase returns introEligible and promoEligible true`() =
336
+ runTest {
337
+ stubConnection()
338
+ stubPurchasesQuery(emptyList())
339
+
340
+ val trialOffer =
341
+ mockSubscriptionOfferDetails(
342
+ offerId = "free-trial-7d",
343
+ phases =
344
+ listOf(
345
+ mockPricingPhase(priceMicros = 0L, billingPeriod = "P1W"),
346
+ mockPricingPhase(priceMicros = 9_990_000L, billingPeriod = "P1M"),
347
+ ),
348
+ )
349
+ val subProduct =
350
+ mockSubscriptionProduct(
351
+ productId = "sub-1",
352
+ extraOffers = listOf(trialOffer),
353
+ )
354
+ stubProductDetailsQuery(listOf(subProduct))
355
+
356
+ val connection = PlayBillingConnection(mockContext, mockBuilder)
357
+ backend.setConnectionForTesting(connection)
358
+ connection.ensureConnected()
359
+
360
+ // Put the product in the cache by calling getProducts first
361
+ backend.getProducts(listOf("sub-1"))
362
+
363
+ val eligibility = backend.checkEligibility(listOf("sub-1"))
364
+ assertEquals(1, eligibility.size)
365
+ assertEquals("sub-1", eligibility[0]["product_ref_id"])
366
+ assertEquals(true, eligibility[0]["intro_eligible"])
367
+ assertEquals(true, eligibility[0]["promo_eligible"])
368
+ }
369
+
370
+ @Test
371
+ fun `checkEligibility after purchase of base plan returns introEligible false`() =
372
+ runTest {
373
+ stubConnection()
374
+
375
+ // Simulate a purchase of sub-1
376
+ val mockPurchase = mockk<Purchase>()
377
+ every { mockPurchase.products } returns listOf("sub-1")
378
+ every { mockPurchase.purchaseState } returns Purchase.PurchaseState.PURCHASED
379
+ every { mockPurchase.isAcknowledged } returns true
380
+ stubPurchasesQuery(listOf(mockPurchase))
381
+
382
+ val trialOffer =
383
+ mockSubscriptionOfferDetails(
384
+ offerId = "free-trial-7d",
385
+ phases =
386
+ listOf(
387
+ mockPricingPhase(priceMicros = 0L, billingPeriod = "P1W"),
388
+ mockPricingPhase(priceMicros = 9_990_000L, billingPeriod = "P1M"),
389
+ ),
390
+ )
391
+ val subProduct =
392
+ mockSubscriptionProduct(
393
+ productId = "sub-1",
394
+ extraOffers = listOf(trialOffer),
395
+ )
396
+ stubProductDetailsQuery(listOf(subProduct))
397
+
398
+ val connection = PlayBillingConnection(mockContext, mockBuilder)
399
+ backend.setConnectionForTesting(connection)
400
+ connection.ensureConnected()
401
+
402
+ // Populate the product cache
403
+ backend.getProducts(listOf("sub-1"))
404
+
405
+ val eligibility = backend.checkEligibility(listOf("sub-1"))
406
+ assertEquals(1, eligibility.size)
407
+ assertEquals("sub-1", eligibility[0]["product_ref_id"])
408
+ assertEquals(false, eligibility[0]["intro_eligible"])
409
+ assertEquals(false, eligibility[0]["promo_eligible"])
410
+ }
411
+
412
+ // ── onTransactionUpdate / onError ──────────────────────────────────
413
+
414
+ @Test
415
+ fun `onTransactionUpdate stores callback`() {
416
+ var received: Map<String, Any?>? = null
417
+ backend.onTransactionUpdate { received = it }
418
+ // Verify callback was stored (internal check via reflection or indirect test)
419
+ assertNotNull("Callback should be set", backend)
420
+ }
421
+
422
+ @Test
423
+ fun `onError stores callback`() {
424
+ var received: Map<String, Any?>? = null
425
+ backend.onError { received = it }
426
+ assertNotNull("Callback should be set", backend)
427
+ }
428
+
429
+ // ── purchase ───────────────────────────────────────────────────────
430
+
431
+ @Test
432
+ fun `purchase calls launchBillingFlow with obfuscatedAccountId`() =
433
+ runTest {
434
+ val inappProduct = mockInAppProduct(productId = "iap-1")
435
+ connectAndCacheProducts(listOf(inappProduct))
436
+
437
+ // Stub launchBillingFlow to return OK
438
+ val flowParamsSlot = slot<BillingFlowParams>()
439
+ every {
440
+ mockBillingClient.launchBillingFlow(any<Activity>(), capture(flowParamsSlot))
441
+ } returns billingResult(BillingResponseCode.OK)
442
+
443
+ // Launch the purchase in a separate coroutine so we can simulate the listener
444
+ val purchaseResult =
445
+ kotlinx.coroutines.CompletableDeferred<Map<String, Any?>>()
446
+ val job =
447
+ launch {
448
+ try {
449
+ purchaseResult.complete(
450
+ backend.purchase(
451
+ mockActivity,
452
+ "iap-1",
453
+ offerId = null,
454
+ offerSignature = null,
455
+ appAccountToken = "test-uuid-123",
456
+ ),
457
+ )
458
+ } catch (e: Exception) {
459
+ purchaseResult.completeExceptionally(e)
460
+ }
461
+ }
462
+ advanceUntilIdle()
463
+
464
+ // Verify launchBillingFlow was called
465
+ verify { mockBillingClient.launchBillingFlow(mockActivity, any()) }
466
+
467
+ // Simulate the listener callback with a successful purchase
468
+ val purchase = mockPurchase(productIds = listOf("iap-1"))
469
+ capturedPurchasesListener?.onPurchasesUpdated(
470
+ billingResult(BillingResponseCode.OK),
471
+ listOf(purchase),
472
+ )
473
+
474
+ val result = purchaseResult.await()
475
+ assertEquals("iap-1", result["skuId"])
476
+ assertEquals("GPA.1234-5678-9012", result["transactionId"])
477
+ assertNotNull(result["receipt"])
478
+ assertNotNull(result["purchaseToken"])
479
+ job.cancel()
480
+ }
481
+
482
+ @Test
483
+ fun `purchase with offerId selects correct offer token`() =
484
+ runTest {
485
+ val trialOffer =
486
+ mockSubscriptionOfferDetails(
487
+ offerId = "promo-offer-1",
488
+ phases =
489
+ listOf(
490
+ mockPricingPhase(priceMicros = 0L, billingPeriod = "P1W"),
491
+ mockPricingPhase(priceMicros = 9_990_000L, billingPeriod = "P1M"),
492
+ ),
493
+ offerToken = "token-promo-offer-1",
494
+ )
495
+ val subProduct =
496
+ mockSubscriptionProduct(
497
+ productId = "sub-1",
498
+ extraOffers = listOf(trialOffer),
499
+ )
500
+ connectAndCacheProducts(listOf(subProduct))
501
+
502
+ every {
503
+ mockBillingClient.launchBillingFlow(any<Activity>(), any())
504
+ } returns billingResult(BillingResponseCode.OK)
505
+
506
+ val purchaseResult =
507
+ kotlinx.coroutines.CompletableDeferred<Map<String, Any?>>()
508
+ val job =
509
+ launch {
510
+ try {
511
+ purchaseResult.complete(
512
+ backend.purchase(
513
+ mockActivity,
514
+ "sub-1",
515
+ offerId = "promo-offer-1",
516
+ offerSignature = null,
517
+ appAccountToken = null,
518
+ ),
519
+ )
520
+ } catch (e: Exception) {
521
+ purchaseResult.completeExceptionally(e)
522
+ }
523
+ }
524
+ advanceUntilIdle()
525
+
526
+ verify { mockBillingClient.launchBillingFlow(mockActivity, any()) }
527
+
528
+ // Simulate successful purchase callback
529
+ val purchase = mockPurchase(productIds = listOf("sub-1"))
530
+ capturedPurchasesListener?.onPurchasesUpdated(
531
+ billingResult(BillingResponseCode.OK),
532
+ listOf(purchase),
533
+ )
534
+
535
+ val result = purchaseResult.await()
536
+ assertEquals("sub-1", result["skuId"])
537
+ job.cancel()
538
+ }
539
+
540
+ @Test
541
+ fun `purchase listener OK resolves continuation and fires transactionListener`() =
542
+ runTest {
543
+ val inappProduct = mockInAppProduct(productId = "iap-1")
544
+ connectAndCacheProducts(listOf(inappProduct))
545
+
546
+ every {
547
+ mockBillingClient.launchBillingFlow(any<Activity>(), any())
548
+ } returns billingResult(BillingResponseCode.OK)
549
+
550
+ var transactionCallbackResult: Map<String, Any?>? = null
551
+ backend.onTransactionUpdate { transactionCallbackResult = it }
552
+
553
+ val purchaseResult =
554
+ kotlinx.coroutines.CompletableDeferred<Map<String, Any?>>()
555
+ val job =
556
+ launch {
557
+ try {
558
+ purchaseResult.complete(
559
+ backend.purchase(
560
+ mockActivity,
561
+ "iap-1",
562
+ offerId = null,
563
+ offerSignature = null,
564
+ appAccountToken = null,
565
+ ),
566
+ )
567
+ } catch (e: Exception) {
568
+ purchaseResult.completeExceptionally(e)
569
+ }
570
+ }
571
+ advanceUntilIdle()
572
+
573
+ // Simulate the listener callback
574
+ val purchase = mockPurchase(productIds = listOf("iap-1"))
575
+ capturedPurchasesListener?.onPurchasesUpdated(
576
+ billingResult(BillingResponseCode.OK),
577
+ listOf(purchase),
578
+ )
579
+
580
+ val result = purchaseResult.await()
581
+ assertEquals("iap-1", result["skuId"])
582
+ assertEquals("test-token", result["purchaseToken"])
583
+ assertEquals(1_700_000_000_000L, result["purchaseTime"])
584
+ assertEquals(false, result["isAcknowledged"])
585
+
586
+ // Verify transactionListener was also called
587
+ assertNotNull("transactionListener should have been called", transactionCallbackResult)
588
+ assertEquals("iap-1", transactionCallbackResult!!["skuId"])
589
+ job.cancel()
590
+ }
591
+
592
+ @Test
593
+ fun `purchase listener USER_CANCELED rejects with UserCancelled and fires errorListener`() =
594
+ runTest {
595
+ val inappProduct = mockInAppProduct(productId = "iap-1")
596
+ connectAndCacheProducts(listOf(inappProduct))
597
+
598
+ every {
599
+ mockBillingClient.launchBillingFlow(any<Activity>(), any())
600
+ } returns billingResult(BillingResponseCode.OK)
601
+
602
+ var errorCallbackResult: Map<String, Any?>? = null
603
+ backend.onError { errorCallbackResult = it }
604
+
605
+ val purchaseResult =
606
+ kotlinx.coroutines.CompletableDeferred<Map<String, Any?>>()
607
+ val job =
608
+ launch {
609
+ try {
610
+ purchaseResult.complete(
611
+ backend.purchase(
612
+ mockActivity,
613
+ "iap-1",
614
+ offerId = null,
615
+ offerSignature = null,
616
+ appAccountToken = null,
617
+ ),
618
+ )
619
+ } catch (e: Exception) {
620
+ purchaseResult.completeExceptionally(e)
621
+ }
622
+ }
623
+ advanceUntilIdle()
624
+
625
+ // Simulate the listener callback with USER_CANCELED
626
+ capturedPurchasesListener?.onPurchasesUpdated(
627
+ billingResult(BillingResponseCode.USER_CANCELED),
628
+ null,
629
+ )
630
+
631
+ try {
632
+ purchaseResult.await()
633
+ fail("Expected exception to be thrown")
634
+ } catch (e: ExpoNamiIapException.UserCancelled) {
635
+ assertEquals("user_cancelled", e.code)
636
+ }
637
+
638
+ // Verify errorListener was also called
639
+ assertNotNull("errorListener should have been called", errorCallbackResult)
640
+ assertEquals("user_cancelled", errorCallbackResult!!["code"])
641
+ job.cancel()
642
+ }
643
+
644
+ @Test
645
+ fun `initConnection is idempotent across repeated calls`() =
646
+ runTest {
647
+ stubConnection()
648
+
649
+ val connection = PlayBillingConnection(mockContext, mockBuilder)
650
+ backend.setConnectionForTesting(connection)
651
+
652
+ // First call
653
+ connection.ensureConnected()
654
+ val client1 = connection.billingClient
655
+
656
+ // Second call should return immediately (isReady == true)
657
+ every { mockBillingClient.isReady } returns true
658
+ val client2 = connection.ensureConnected()
659
+
660
+ // Should be the same client instance
661
+ assertTrue("Should return same client", client1 === client2)
662
+ }
663
+
664
+ // ── finishTransaction ─────────────────────────────────────────────
665
+
666
+ @Test
667
+ fun `finishTransaction on cached subscription calls acknowledgePurchase`() =
668
+ runTest {
669
+ val subProduct = mockSubscriptionProduct(productId = "sub-1")
670
+ connectAndCacheProducts(listOf(subProduct))
671
+
672
+ // Stub acknowledgePurchase
673
+ coEvery {
674
+ mockBillingClient.acknowledgePurchase(any<AcknowledgePurchaseParams>())
675
+ } returns billingResult(BillingResponseCode.OK)
676
+
677
+ // Simulate a purchase via the listener so the purchase gets cached
678
+ every {
679
+ mockBillingClient.launchBillingFlow(any<Activity>(), any())
680
+ } returns billingResult(BillingResponseCode.OK)
681
+
682
+ val purchase =
683
+ mockPurchase(
684
+ productIds = listOf("sub-1"),
685
+ purchaseToken = "sub-token-123",
686
+ )
687
+
688
+ val purchaseResult = kotlinx.coroutines.CompletableDeferred<Map<String, Any?>>()
689
+ val job =
690
+ launch {
691
+ try {
692
+ purchaseResult.complete(
693
+ backend.purchase(
694
+ mockActivity,
695
+ "sub-1",
696
+ offerId = null,
697
+ offerSignature = null,
698
+ appAccountToken = null,
699
+ ),
700
+ )
701
+ } catch (e: Exception) {
702
+ purchaseResult.completeExceptionally(e)
703
+ }
704
+ }
705
+ advanceUntilIdle()
706
+
707
+ // Fire the PurchasesUpdatedListener to cache the purchase
708
+ capturedPurchasesListener?.onPurchasesUpdated(
709
+ billingResult(BillingResponseCode.OK),
710
+ listOf(purchase),
711
+ )
712
+ purchaseResult.await()
713
+ job.cancel()
714
+
715
+ // Now finish the transaction
716
+ backend.finishTransaction("sub-token-123")
717
+
718
+ coVerify {
719
+ mockBillingClient.acknowledgePurchase(
720
+ match<AcknowledgePurchaseParams> {
721
+ it.purchaseToken == "sub-token-123"
722
+ },
723
+ )
724
+ }
725
+ }
726
+
727
+ @Test
728
+ fun `finishTransaction on cached consumable calls consumePurchase`() =
729
+ runTest {
730
+ val inappProduct = mockInAppProduct(productId = "iap-1")
731
+ connectAndCacheProducts(listOf(inappProduct))
732
+
733
+ // Stub consumePurchase — returns a mock ConsumeResult
734
+ coEvery {
735
+ mockBillingClient.consumePurchase(any<ConsumeParams>())
736
+ } returns mockk(relaxed = true)
737
+
738
+ // Simulate a purchase via the listener so the purchase gets cached
739
+ every {
740
+ mockBillingClient.launchBillingFlow(any<Activity>(), any())
741
+ } returns billingResult(BillingResponseCode.OK)
742
+
743
+ val purchase =
744
+ mockPurchase(
745
+ productIds = listOf("iap-1"),
746
+ purchaseToken = "iap-token-456",
747
+ )
748
+
749
+ val purchaseResult = kotlinx.coroutines.CompletableDeferred<Map<String, Any?>>()
750
+ val job =
751
+ launch {
752
+ try {
753
+ purchaseResult.complete(
754
+ backend.purchase(
755
+ mockActivity,
756
+ "iap-1",
757
+ offerId = null,
758
+ offerSignature = null,
759
+ appAccountToken = null,
760
+ ),
761
+ )
762
+ } catch (e: Exception) {
763
+ purchaseResult.completeExceptionally(e)
764
+ }
765
+ }
766
+ advanceUntilIdle()
767
+
768
+ // Fire the PurchasesUpdatedListener to cache the purchase
769
+ capturedPurchasesListener?.onPurchasesUpdated(
770
+ billingResult(BillingResponseCode.OK),
771
+ listOf(purchase),
772
+ )
773
+ purchaseResult.await()
774
+ job.cancel()
775
+
776
+ // Now finish the transaction
777
+ backend.finishTransaction("iap-token-456")
778
+
779
+ coVerify {
780
+ mockBillingClient.consumePurchase(
781
+ match<ConsumeParams> {
782
+ it.purchaseToken == "iap-token-456"
783
+ },
784
+ )
785
+ }
786
+ }
787
+
788
+ // ── restorePurchases ──────────────────────────────────────────────
789
+
790
+ @Test
791
+ fun `restorePurchases returns mapped results for both SUBS and INAPP`() =
792
+ runTest {
793
+ val subProduct = mockSubscriptionProduct(productId = "sub-1")
794
+ val inappProduct = mockInAppProduct(productId = "iap-1")
795
+ connectAndCacheProducts(listOf(subProduct, inappProduct))
796
+
797
+ val subPurchase =
798
+ mockPurchase(
799
+ productIds = listOf("sub-1"),
800
+ orderId = "GPA.sub-order-1",
801
+ purchaseToken = "sub-token-1",
802
+ purchaseTime = 1_700_000_000_000L,
803
+ )
804
+ val inappPurchase =
805
+ mockPurchase(
806
+ productIds = listOf("iap-1"),
807
+ orderId = "GPA.iap-order-1",
808
+ purchaseToken = "iap-token-1",
809
+ purchaseTime = 1_700_000_001_000L,
810
+ )
811
+
812
+ // Stub queryPurchasesAsync to return different results per product type
813
+ val subsResult = mockk<PurchasesResult>()
814
+ every { subsResult.billingResult } returns billingResult(BillingResponseCode.OK)
815
+ every { subsResult.purchasesList } returns listOf(subPurchase)
816
+
817
+ val inappResult = mockk<PurchasesResult>()
818
+ every { inappResult.billingResult } returns billingResult(BillingResponseCode.OK)
819
+ every { inappResult.purchasesList } returns listOf(inappPurchase)
820
+
821
+ coEvery {
822
+ mockBillingClient.queryPurchasesAsync(
823
+ match<QueryPurchasesParams> { true },
824
+ )
825
+ } returns subsResult andThen inappResult
826
+
827
+ val restored = backend.restorePurchases()
828
+
829
+ assertEquals(2, restored.size)
830
+
831
+ val subEntry = restored.first { it["skuId"] == "sub-1" }
832
+ assertEquals("GPA.sub-order-1", subEntry["transactionId"])
833
+ assertEquals("sub-token-1", subEntry["purchaseToken"])
834
+ assertEquals(1_700_000_000_000L, subEntry["purchaseTime"])
835
+
836
+ val inappEntry = restored.first { it["skuId"] == "iap-1" }
837
+ assertEquals("GPA.iap-order-1", inappEntry["transactionId"])
838
+ assertEquals("iap-token-1", inappEntry["purchaseToken"])
839
+ assertEquals(1_700_000_001_000L, inappEntry["purchaseTime"])
840
+ }
841
+ }