@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,974 @@
1
+ package ml.nami.expo.iap.amazon
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import com.amazon.device.iap.PurchasingListener
6
+ import com.amazon.device.iap.PurchasingService
7
+ import com.amazon.device.iap.model.FulfillmentResult
8
+ import com.amazon.device.iap.model.Product
9
+ import com.amazon.device.iap.model.ProductDataResponse
10
+ import com.amazon.device.iap.model.ProductType
11
+ import com.amazon.device.iap.model.PurchaseResponse
12
+ import com.amazon.device.iap.model.PurchaseUpdatesResponse
13
+ import com.amazon.device.iap.model.Receipt
14
+ import com.amazon.device.iap.model.RequestId
15
+ import io.mockk.every
16
+ import io.mockk.mockk
17
+ import io.mockk.mockkStatic
18
+ import io.mockk.slot
19
+ import io.mockk.unmockkStatic
20
+ import io.mockk.verify
21
+ import kotlinx.coroutines.CoroutineScope
22
+ import kotlinx.coroutines.launch
23
+ import kotlinx.coroutines.test.StandardTestDispatcher
24
+ import kotlinx.coroutines.test.advanceTimeBy
25
+ import kotlinx.coroutines.test.advanceUntilIdle
26
+ import kotlinx.coroutines.test.runTest
27
+ import ml.nami.expo.iap.ExpoNamiIapException
28
+ import org.junit.After
29
+ import org.junit.Assert.assertEquals
30
+ import org.junit.Assert.assertNotNull
31
+ import org.junit.Assert.assertTrue
32
+ import org.junit.Assert.fail
33
+ import org.junit.Before
34
+ import org.junit.Test
35
+ import java.util.Date
36
+
37
+ class AmazonIapBackendTest {
38
+ private lateinit var backend: AmazonIapBackend
39
+ private lateinit var mockContext: Context
40
+ private lateinit var mockActivity: Activity
41
+ private var capturedListener: PurchasingListener? = null
42
+
43
+ @Before
44
+ fun setUp() {
45
+ mockContext = mockk(relaxed = true)
46
+ mockActivity = mockk(relaxed = true)
47
+ backend = AmazonIapBackend()
48
+
49
+ ExpoNamiIapAmazon.resetForTesting()
50
+
51
+ mockkStatic(PurchasingService::class)
52
+
53
+ val listenerSlot = slot<PurchasingListener>()
54
+ every {
55
+ PurchasingService.registerListener(any(), capture(listenerSlot))
56
+ } answers {
57
+ capturedListener = listenerSlot.captured
58
+ }
59
+ }
60
+
61
+ @After
62
+ fun tearDown() {
63
+ unmockkStatic(PurchasingService::class)
64
+ ExpoNamiIapAmazon.resetForTesting()
65
+ }
66
+
67
+ // ── Helpers ─────────────────────────────────────────────────────────
68
+
69
+ private fun mockRequestId(value: String = "req-001"): RequestId {
70
+ val reqId = mockk<RequestId>()
71
+ every { reqId.toString() } returns value
72
+ return reqId
73
+ }
74
+
75
+ private fun mockProduct(
76
+ sku: String = "com.example.sku",
77
+ title: String = "Test Product",
78
+ description: String = "A test product",
79
+ price: String = "$9.99",
80
+ productType: ProductType = ProductType.SUBSCRIPTION,
81
+ ): Product {
82
+ val product = mockk<Product>()
83
+ every { product.sku } returns sku
84
+ every { product.title } returns title
85
+ every { product.description } returns description
86
+ every { product.price } returns price
87
+ every { product.productType } returns productType
88
+ return product
89
+ }
90
+
91
+ private fun mockProductDataResponse(
92
+ requestId: RequestId,
93
+ status: ProductDataResponse.RequestStatus,
94
+ products: Map<String, Product> = emptyMap(),
95
+ ): ProductDataResponse {
96
+ val response = mockk<ProductDataResponse>()
97
+ every { response.requestId } returns requestId
98
+ every { response.requestStatus } returns status
99
+ every { response.productData } returns products
100
+ every { response.unavailableSkus } returns emptySet()
101
+ return response
102
+ }
103
+
104
+ private fun mockReceipt(
105
+ receiptId: String = "receipt-001",
106
+ sku: String = "sku-a",
107
+ purchaseDate: Date = Date(1_700_000_000_000L),
108
+ cancelDate: Date? = null,
109
+ ): Receipt {
110
+ val receipt = mockk<Receipt>()
111
+ every { receipt.receiptId } returns receiptId
112
+ every { receipt.sku } returns sku
113
+ every { receipt.purchaseDate } returns purchaseDate
114
+ every { receipt.cancelDate } returns cancelDate
115
+ return receipt
116
+ }
117
+
118
+ private fun mockPurchaseResponse(
119
+ requestId: RequestId,
120
+ status: PurchaseResponse.RequestStatus,
121
+ receipt: Receipt? = null,
122
+ ): PurchaseResponse {
123
+ val response = mockk<PurchaseResponse>()
124
+ every { response.requestId } returns requestId
125
+ every { response.requestStatus } returns status
126
+ if (receipt != null) {
127
+ every { response.receipt } returns receipt
128
+ }
129
+ return response
130
+ }
131
+
132
+ private fun mockPurchaseUpdatesResponse(
133
+ requestId: RequestId,
134
+ status: PurchaseUpdatesResponse.RequestStatus,
135
+ receipts: List<Receipt> = emptyList(),
136
+ ): PurchaseUpdatesResponse {
137
+ val response = mockk<PurchaseUpdatesResponse>()
138
+ every { response.requestId } returns requestId
139
+ every { response.requestStatus } returns status
140
+ every { response.receipts } returns receipts
141
+ every { response.hasMore() } returns false
142
+ return response
143
+ }
144
+
145
+ // ── getProducts ────────────────────────────────────────────────────
146
+
147
+ @Test
148
+ fun `getProducts calls PurchasingService getProductData and resolves via listener`() =
149
+ runTest {
150
+ val reqId = mockRequestId("req-001")
151
+ every { PurchasingService.getProductData(any()) } returns reqId
152
+
153
+ val product = mockProduct(sku = "sku-a", price = "$9.99")
154
+
155
+ // Connect first
156
+ backend.initConnection(mockContext)
157
+
158
+ val resultDeferred = kotlinx.coroutines.CompletableDeferred<List<Map<String, Any?>>>()
159
+ val job =
160
+ launch {
161
+ try {
162
+ resultDeferred.complete(backend.getProducts(listOf("sku-a")))
163
+ } catch (e: Exception) {
164
+ resultDeferred.completeExceptionally(e)
165
+ }
166
+ }
167
+ advanceUntilIdle()
168
+
169
+ // Verify PurchasingService.getProductData was called
170
+ verify { PurchasingService.getProductData(match { "sku-a" in it }) }
171
+
172
+ // Simulate the listener response
173
+ val response =
174
+ mockProductDataResponse(
175
+ requestId = reqId,
176
+ status = ProductDataResponse.RequestStatus.SUCCESSFUL,
177
+ products = mapOf("sku-a" to product),
178
+ )
179
+ capturedListener?.onProductDataResponse(response)
180
+
181
+ val result = resultDeferred.await()
182
+ assertEquals(1, result.size)
183
+ assertEquals("sku-a", result[0]["product_ref_id"])
184
+ assertEquals("Test Product", result[0]["name"])
185
+ job.cancel()
186
+ }
187
+
188
+ @Test
189
+ fun `getProducts auto-registers listener if not yet registered`() =
190
+ runTest {
191
+ val reqId = mockRequestId("req-002")
192
+ every { PurchasingService.getProductData(any()) } returns reqId
193
+
194
+ val product = mockProduct(sku = "sku-b")
195
+
196
+ // Do NOT call initConnection — getProducts should auto-register
197
+ backend.setContextForTesting(mockContext)
198
+
199
+ val resultDeferred = kotlinx.coroutines.CompletableDeferred<List<Map<String, Any?>>>()
200
+ val job =
201
+ launch {
202
+ try {
203
+ resultDeferred.complete(backend.getProducts(listOf("sku-b")))
204
+ } catch (e: Exception) {
205
+ resultDeferred.completeExceptionally(e)
206
+ }
207
+ }
208
+ advanceUntilIdle()
209
+
210
+ // Verify registerListener was called auto-defensively
211
+ verify { PurchasingService.registerListener(mockContext, any()) }
212
+
213
+ // Simulate the listener response
214
+ val response =
215
+ mockProductDataResponse(
216
+ requestId = reqId,
217
+ status = ProductDataResponse.RequestStatus.SUCCESSFUL,
218
+ products = mapOf("sku-b" to product),
219
+ )
220
+ capturedListener?.onProductDataResponse(response)
221
+
222
+ val result = resultDeferred.await()
223
+ assertEquals(1, result.size)
224
+ assertEquals("sku-b", result[0]["product_ref_id"])
225
+ job.cancel()
226
+ }
227
+
228
+ @Test
229
+ fun `getProducts with FAILED status rejects with exception`() =
230
+ runTest {
231
+ val reqId = mockRequestId("req-003")
232
+ every { PurchasingService.getProductData(any()) } returns reqId
233
+
234
+ backend.initConnection(mockContext)
235
+
236
+ val resultDeferred = kotlinx.coroutines.CompletableDeferred<List<Map<String, Any?>>>()
237
+ val job =
238
+ launch {
239
+ try {
240
+ resultDeferred.complete(backend.getProducts(listOf("sku-c")))
241
+ } catch (e: Exception) {
242
+ resultDeferred.completeExceptionally(e)
243
+ }
244
+ }
245
+ advanceUntilIdle()
246
+
247
+ // Simulate a FAILED response
248
+ val response =
249
+ mockProductDataResponse(
250
+ requestId = reqId,
251
+ status = ProductDataResponse.RequestStatus.FAILED,
252
+ )
253
+ capturedListener?.onProductDataResponse(response)
254
+
255
+ try {
256
+ resultDeferred.await()
257
+ fail("Expected exception to be thrown")
258
+ } catch (e: ExpoNamiIapException.Unknown) {
259
+ assertTrue(e.message!!.contains("Product data failed"))
260
+ }
261
+ job.cancel()
262
+ }
263
+
264
+ @Test
265
+ fun `getProducts with NOT_SUPPORTED status rejects with exception`() =
266
+ runTest {
267
+ val reqId = mockRequestId("req-004")
268
+ every { PurchasingService.getProductData(any()) } returns reqId
269
+
270
+ backend.initConnection(mockContext)
271
+
272
+ val resultDeferred = kotlinx.coroutines.CompletableDeferred<List<Map<String, Any?>>>()
273
+ val job =
274
+ launch {
275
+ try {
276
+ resultDeferred.complete(backend.getProducts(listOf("sku-d")))
277
+ } catch (e: Exception) {
278
+ resultDeferred.completeExceptionally(e)
279
+ }
280
+ }
281
+ advanceUntilIdle()
282
+
283
+ val response =
284
+ mockProductDataResponse(
285
+ requestId = reqId,
286
+ status = ProductDataResponse.RequestStatus.NOT_SUPPORTED,
287
+ )
288
+ capturedListener?.onProductDataResponse(response)
289
+
290
+ try {
291
+ resultDeferred.await()
292
+ fail("Expected exception to be thrown")
293
+ } catch (e: ExpoNamiIapException.Unknown) {
294
+ assertTrue(e.message!!.contains("NOT_SUPPORTED"))
295
+ }
296
+ job.cancel()
297
+ }
298
+
299
+ // ── Price parsing ──────────────────────────────────────────────────
300
+
301
+ @Test
302
+ fun `price string parsing extracts correct amount`() {
303
+ val product = mockProduct(sku = "sku-price", price = "$9.99")
304
+ val mapped = AmazonProductMapper.toMap(product)
305
+
306
+ @Suppress("UNCHECKED_CAST")
307
+ val offers = mapped["offers"] as List<Map<String, Any?>>
308
+ assertEquals(1, offers.size)
309
+ assertEquals(9.99, offers[0]["price"] as Double, 0.001)
310
+ }
311
+
312
+ @Test
313
+ fun `price string with non-USD currency is parsed`() {
314
+ val product = mockProduct(sku = "sku-eur", price = "EUR 12.49")
315
+ val mapped = AmazonProductMapper.toMap(product)
316
+
317
+ @Suppress("UNCHECKED_CAST")
318
+ val offers = mapped["offers"] as List<Map<String, Any?>>
319
+ assertEquals(12.49, offers[0]["price"] as Double, 0.001)
320
+ }
321
+
322
+ @Test
323
+ fun `price string with comma decimal separator is parsed`() {
324
+ val product = mockProduct(sku = "sku-comma", price = "9,99 EUR")
325
+ val mapped = AmazonProductMapper.toMap(product)
326
+
327
+ @Suppress("UNCHECKED_CAST")
328
+ val offers = mapped["offers"] as List<Map<String, Any?>>
329
+ assertEquals(9.99, offers[0]["price"] as Double, 0.001)
330
+ }
331
+
332
+ // ── Product type mapping ───────────────────────────────────────────
333
+
334
+ @Test
335
+ fun `CONSUMABLE maps to one_time_purchase`() {
336
+ val product = mockProduct(productType = ProductType.CONSUMABLE)
337
+ val mapped = AmazonProductMapper.toMap(product)
338
+ assertEquals("one_time_purchase", mapped["product_type"])
339
+ }
340
+
341
+ @Test
342
+ fun `ENTITLED maps to one_time_purchase`() {
343
+ val product = mockProduct(productType = ProductType.ENTITLED)
344
+ val mapped = AmazonProductMapper.toMap(product)
345
+ assertEquals("one_time_purchase", mapped["product_type"])
346
+ }
347
+
348
+ @Test
349
+ fun `SUBSCRIPTION maps to subscription`() {
350
+ val product = mockProduct(productType = ProductType.SUBSCRIPTION)
351
+ val mapped = AmazonProductMapper.toMap(product)
352
+ assertEquals("subscription", mapped["product_type"])
353
+ }
354
+
355
+ // ── Listener callbacks ─────────────────────────────────────────────
356
+
357
+ @Test
358
+ fun `onTransactionUpdate stores callback`() {
359
+ var received: Map<String, Any?>? = null
360
+ backend.onTransactionUpdate { received = it }
361
+ assertNotNull("Callback should be set", backend)
362
+ }
363
+
364
+ @Test
365
+ fun `onError stores callback`() {
366
+ var received: Map<String, Any?>? = null
367
+ backend.onError { received = it }
368
+ assertNotNull("Callback should be set", backend)
369
+ }
370
+
371
+ // ── initConnection / endConnection ─────────────────────────────────
372
+
373
+ @Test
374
+ fun `initConnection registers listener with PurchasingService`() =
375
+ runTest {
376
+ backend.initConnection(mockContext)
377
+ verify { PurchasingService.registerListener(mockContext, any()) }
378
+ }
379
+
380
+ @Test
381
+ fun `endConnection clears state`() =
382
+ runTest {
383
+ backend.initConnection(mockContext)
384
+ backend.endConnection()
385
+ // After endConnection, getProducts should auto-register again
386
+ // We just verify no exception is thrown by endConnection
387
+ }
388
+
389
+ // ── purchase ───────────────────────────────────────────────────────
390
+
391
+ @Test
392
+ fun `purchase calls PurchasingService purchase and resolves via listener`() =
393
+ runTest {
394
+ val reqId = mockRequestId("purchase-req-001")
395
+ every { PurchasingService.purchase(any<String>()) } returns reqId
396
+
397
+ backend.initConnection(mockContext)
398
+
399
+ val receipt = mockReceipt(receiptId = "rcpt-001", sku = "sku-a")
400
+ var transactionResult: Map<String, Any?>? = null
401
+ backend.onTransactionUpdate { transactionResult = it }
402
+
403
+ val purchaseResult =
404
+ kotlinx.coroutines.CompletableDeferred<Map<String, Any?>>()
405
+ val job =
406
+ launch {
407
+ try {
408
+ purchaseResult.complete(
409
+ backend.purchase(
410
+ mockActivity,
411
+ "sku-a",
412
+ offerId = null,
413
+ offerSignature = null,
414
+ appAccountToken = null,
415
+ ),
416
+ )
417
+ } catch (e: Exception) {
418
+ purchaseResult.completeExceptionally(e)
419
+ }
420
+ }
421
+ advanceUntilIdle()
422
+
423
+ // Verify PurchasingService.purchase was called
424
+ verify { PurchasingService.purchase("sku-a") }
425
+
426
+ // Simulate SUCCESSFUL purchase response
427
+ val response =
428
+ mockPurchaseResponse(
429
+ requestId = reqId,
430
+ status = PurchaseResponse.RequestStatus.SUCCESSFUL,
431
+ receipt = receipt,
432
+ )
433
+ capturedListener?.onPurchaseResponse(response)
434
+
435
+ val result = purchaseResult.await()
436
+ assertEquals("sku-a", result["skuId"])
437
+ assertEquals("rcpt-001", result["transactionId"])
438
+ assertEquals("rcpt-001", result["originalTransactionId"])
439
+ assertEquals("rcpt-001", result["receipt"])
440
+ assertEquals("rcpt-001", result["purchaseToken"])
441
+ assertEquals(1_700_000_000_000L, result["purchaseTime"])
442
+
443
+ // Verify transactionListener was also called
444
+ assertNotNull("transactionListener should have been called", transactionResult)
445
+ assertEquals("sku-a", transactionResult!!["skuId"])
446
+ job.cancel()
447
+ }
448
+
449
+ @Test
450
+ fun `purchase FAILED rejects with Unknown and fires errorListener`() =
451
+ runTest {
452
+ val reqId = mockRequestId("purchase-req-002")
453
+ every { PurchasingService.purchase(any<String>()) } returns reqId
454
+
455
+ backend.initConnection(mockContext)
456
+
457
+ var errorResult: Map<String, Any?>? = null
458
+ backend.onError { errorResult = it }
459
+
460
+ val purchaseResult =
461
+ kotlinx.coroutines.CompletableDeferred<Map<String, Any?>>()
462
+ val job =
463
+ launch {
464
+ try {
465
+ purchaseResult.complete(
466
+ backend.purchase(
467
+ mockActivity,
468
+ "sku-a",
469
+ offerId = null,
470
+ offerSignature = null,
471
+ appAccountToken = null,
472
+ ),
473
+ )
474
+ } catch (e: Exception) {
475
+ purchaseResult.completeExceptionally(e)
476
+ }
477
+ }
478
+ advanceUntilIdle()
479
+
480
+ // Simulate FAILED purchase response
481
+ val response =
482
+ mockPurchaseResponse(
483
+ requestId = reqId,
484
+ status = PurchaseResponse.RequestStatus.FAILED,
485
+ )
486
+ capturedListener?.onPurchaseResponse(response)
487
+
488
+ try {
489
+ purchaseResult.await()
490
+ fail("Expected exception to be thrown")
491
+ } catch (e: ExpoNamiIapException.Unknown) {
492
+ assertEquals("unknown", e.code)
493
+ }
494
+
495
+ // Verify errorListener was also called
496
+ assertNotNull("errorListener should have been called", errorResult)
497
+ assertEquals("unknown", errorResult!!["code"])
498
+ job.cancel()
499
+ }
500
+
501
+ @Test
502
+ fun `purchase INVALID_SKU rejects with ItemUnavailable`() =
503
+ runTest {
504
+ val reqId = mockRequestId("purchase-req-003")
505
+ every { PurchasingService.purchase(any<String>()) } returns reqId
506
+
507
+ backend.initConnection(mockContext)
508
+
509
+ val purchaseResult =
510
+ kotlinx.coroutines.CompletableDeferred<Map<String, Any?>>()
511
+ val job =
512
+ launch {
513
+ try {
514
+ purchaseResult.complete(
515
+ backend.purchase(
516
+ mockActivity,
517
+ "bad-sku",
518
+ offerId = null,
519
+ offerSignature = null,
520
+ appAccountToken = null,
521
+ ),
522
+ )
523
+ } catch (e: Exception) {
524
+ purchaseResult.completeExceptionally(e)
525
+ }
526
+ }
527
+ advanceUntilIdle()
528
+
529
+ val response =
530
+ mockPurchaseResponse(
531
+ requestId = reqId,
532
+ status = PurchaseResponse.RequestStatus.INVALID_SKU,
533
+ )
534
+ capturedListener?.onPurchaseResponse(response)
535
+
536
+ try {
537
+ purchaseResult.await()
538
+ fail("Expected exception to be thrown")
539
+ } catch (e: ExpoNamiIapException.ItemUnavailable) {
540
+ assertEquals("item_unavailable", e.code)
541
+ }
542
+ job.cancel()
543
+ }
544
+
545
+ @Test
546
+ fun `purchase ALREADY_PURCHASED rejects with AlreadyOwned`() =
547
+ runTest {
548
+ val reqId = mockRequestId("purchase-req-004")
549
+ every { PurchasingService.purchase(any<String>()) } returns reqId
550
+
551
+ backend.initConnection(mockContext)
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
+ "sku-a",
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
+ val response =
574
+ mockPurchaseResponse(
575
+ requestId = reqId,
576
+ status = PurchaseResponse.RequestStatus.ALREADY_PURCHASED,
577
+ )
578
+ capturedListener?.onPurchaseResponse(response)
579
+
580
+ try {
581
+ purchaseResult.await()
582
+ fail("Expected exception to be thrown")
583
+ } catch (e: ExpoNamiIapException.AlreadyOwned) {
584
+ assertEquals("already_owned", e.code)
585
+ }
586
+ job.cancel()
587
+ }
588
+
589
+ @Test
590
+ fun `purchase NOT_SUPPORTED rejects with NotAllowed`() =
591
+ runTest {
592
+ val reqId = mockRequestId("purchase-req-005")
593
+ every { PurchasingService.purchase(any<String>()) } returns reqId
594
+
595
+ backend.initConnection(mockContext)
596
+
597
+ val purchaseResult =
598
+ kotlinx.coroutines.CompletableDeferred<Map<String, Any?>>()
599
+ val job =
600
+ launch {
601
+ try {
602
+ purchaseResult.complete(
603
+ backend.purchase(
604
+ mockActivity,
605
+ "sku-a",
606
+ offerId = null,
607
+ offerSignature = null,
608
+ appAccountToken = null,
609
+ ),
610
+ )
611
+ } catch (e: Exception) {
612
+ purchaseResult.completeExceptionally(e)
613
+ }
614
+ }
615
+ advanceUntilIdle()
616
+
617
+ val response =
618
+ mockPurchaseResponse(
619
+ requestId = reqId,
620
+ status = PurchaseResponse.RequestStatus.NOT_SUPPORTED,
621
+ )
622
+ capturedListener?.onPurchaseResponse(response)
623
+
624
+ try {
625
+ purchaseResult.await()
626
+ fail("Expected exception to be thrown")
627
+ } catch (e: ExpoNamiIapException.NotAllowed) {
628
+ assertEquals("not_allowed", e.code)
629
+ }
630
+ job.cancel()
631
+ }
632
+
633
+ // ── restorePurchases ──────────────────────────────────────────────
634
+
635
+ @Test
636
+ fun `restorePurchases calls getPurchaseUpdates and returns mapped receipts`() =
637
+ runTest {
638
+ val reqId = mockRequestId("restore-req-001")
639
+ every { PurchasingService.getPurchaseUpdates(false) } returns reqId
640
+
641
+ backend.initConnection(mockContext)
642
+
643
+ val receipt1 = mockReceipt(receiptId = "rcpt-1", sku = "sku-a")
644
+ val receipt2 =
645
+ mockReceipt(
646
+ receiptId = "rcpt-2",
647
+ sku = "sku-b",
648
+ purchaseDate = Date(1_700_000_001_000L),
649
+ )
650
+
651
+ val transactionResults = mutableListOf<Map<String, Any?>>()
652
+ backend.onTransactionUpdate { transactionResults.add(it) }
653
+
654
+ val resultDeferred =
655
+ kotlinx.coroutines.CompletableDeferred<List<Map<String, Any?>>>()
656
+ val job =
657
+ launch {
658
+ try {
659
+ resultDeferred.complete(backend.restorePurchases())
660
+ } catch (e: Exception) {
661
+ resultDeferred.completeExceptionally(e)
662
+ }
663
+ }
664
+ advanceUntilIdle()
665
+
666
+ // Verify getPurchaseUpdates was called
667
+ verify { PurchasingService.getPurchaseUpdates(false) }
668
+
669
+ // Simulate SUCCESSFUL response
670
+ val response =
671
+ mockPurchaseUpdatesResponse(
672
+ requestId = reqId,
673
+ status = PurchaseUpdatesResponse.RequestStatus.SUCCESSFUL,
674
+ receipts = listOf(receipt1, receipt2),
675
+ )
676
+ capturedListener?.onPurchaseUpdatesResponse(response)
677
+
678
+ val result = resultDeferred.await()
679
+ assertEquals(2, result.size)
680
+
681
+ val first = result[0]
682
+ assertEquals("sku-a", first["skuId"])
683
+ assertEquals("rcpt-1", first["transactionId"])
684
+ assertEquals("rcpt-1", first["purchaseToken"])
685
+ assertEquals(1_700_000_000_000L, first["purchaseTime"])
686
+
687
+ val second = result[1]
688
+ assertEquals("sku-b", second["skuId"])
689
+ assertEquals("rcpt-2", second["transactionId"])
690
+ assertEquals(1_700_000_001_000L, second["purchaseTime"])
691
+
692
+ // Verify transactionListener was called for each receipt
693
+ assertEquals(2, transactionResults.size)
694
+ assertEquals("sku-a", transactionResults[0]["skuId"])
695
+ assertEquals("sku-b", transactionResults[1]["skuId"])
696
+ job.cancel()
697
+ }
698
+
699
+ // ── checkEligibility ──────────────────────────────────────────────
700
+
701
+ @Test
702
+ fun `checkEligibility with no existing receipt returns both booleans true`() =
703
+ runTest {
704
+ val reqId = mockRequestId("elig-req-001")
705
+ every { PurchasingService.getPurchaseUpdates(false) } returns reqId
706
+
707
+ backend.initConnection(mockContext)
708
+
709
+ val resultDeferred =
710
+ kotlinx.coroutines.CompletableDeferred<List<Map<String, Any?>>>()
711
+ val job =
712
+ launch {
713
+ try {
714
+ resultDeferred.complete(backend.checkEligibility(listOf("sku-a")))
715
+ } catch (e: Exception) {
716
+ resultDeferred.completeExceptionally(e)
717
+ }
718
+ }
719
+ advanceUntilIdle()
720
+
721
+ // Simulate empty receipts
722
+ val response =
723
+ mockPurchaseUpdatesResponse(
724
+ requestId = reqId,
725
+ status = PurchaseUpdatesResponse.RequestStatus.SUCCESSFUL,
726
+ receipts = emptyList(),
727
+ )
728
+ capturedListener?.onPurchaseUpdatesResponse(response)
729
+
730
+ val result = resultDeferred.await()
731
+ assertEquals(1, result.size)
732
+ assertEquals("sku-a", result[0]["product_ref_id"])
733
+ assertEquals(true, result[0]["intro_eligible"])
734
+ assertEquals(true, result[0]["promo_eligible"])
735
+ job.cancel()
736
+ }
737
+
738
+ @Test
739
+ fun `checkEligibility with active receipt returns both booleans false`() =
740
+ runTest {
741
+ val reqId = mockRequestId("elig-req-002")
742
+ every { PurchasingService.getPurchaseUpdates(false) } returns reqId
743
+
744
+ backend.initConnection(mockContext)
745
+
746
+ val activeReceipt =
747
+ mockReceipt(
748
+ receiptId = "rcpt-active",
749
+ sku = "sku-a",
750
+ cancelDate = null,
751
+ )
752
+
753
+ val resultDeferred =
754
+ kotlinx.coroutines.CompletableDeferred<List<Map<String, Any?>>>()
755
+ val job =
756
+ launch {
757
+ try {
758
+ resultDeferred.complete(backend.checkEligibility(listOf("sku-a")))
759
+ } catch (e: Exception) {
760
+ resultDeferred.completeExceptionally(e)
761
+ }
762
+ }
763
+ advanceUntilIdle()
764
+
765
+ // Simulate receipts with an active subscription for sku-a
766
+ val response =
767
+ mockPurchaseUpdatesResponse(
768
+ requestId = reqId,
769
+ status = PurchaseUpdatesResponse.RequestStatus.SUCCESSFUL,
770
+ receipts = listOf(activeReceipt),
771
+ )
772
+ capturedListener?.onPurchaseUpdatesResponse(response)
773
+
774
+ val result = resultDeferred.await()
775
+ assertEquals(1, result.size)
776
+ assertEquals("sku-a", result[0]["product_ref_id"])
777
+ assertEquals(false, result[0]["intro_eligible"])
778
+ assertEquals(false, result[0]["promo_eligible"])
779
+ job.cancel()
780
+ }
781
+
782
+ @Test
783
+ fun `checkEligibility with cancelled receipt returns both booleans true`() =
784
+ runTest {
785
+ val reqId = mockRequestId("elig-req-003")
786
+ every { PurchasingService.getPurchaseUpdates(false) } returns reqId
787
+
788
+ backend.initConnection(mockContext)
789
+
790
+ val cancelledReceipt =
791
+ mockReceipt(
792
+ receiptId = "rcpt-cancelled",
793
+ sku = "sku-a",
794
+ cancelDate = Date(1_700_000_999_000L),
795
+ )
796
+
797
+ val resultDeferred =
798
+ kotlinx.coroutines.CompletableDeferred<List<Map<String, Any?>>>()
799
+ val job =
800
+ launch {
801
+ try {
802
+ resultDeferred.complete(backend.checkEligibility(listOf("sku-a")))
803
+ } catch (e: Exception) {
804
+ resultDeferred.completeExceptionally(e)
805
+ }
806
+ }
807
+ advanceUntilIdle()
808
+
809
+ val response =
810
+ mockPurchaseUpdatesResponse(
811
+ requestId = reqId,
812
+ status = PurchaseUpdatesResponse.RequestStatus.SUCCESSFUL,
813
+ receipts = listOf(cancelledReceipt),
814
+ )
815
+ capturedListener?.onPurchaseUpdatesResponse(response)
816
+
817
+ val result = resultDeferred.await()
818
+ assertEquals(1, result.size)
819
+ assertEquals("sku-a", result[0]["product_ref_id"])
820
+ assertEquals(true, result[0]["intro_eligible"])
821
+ assertEquals(true, result[0]["promo_eligible"])
822
+ job.cancel()
823
+ }
824
+
825
+ // ── finishTransaction ─────────────────────────────────────────────
826
+
827
+ @Test
828
+ fun `finishTransaction calls notifyFulfillment with FULFILLED`() =
829
+ runTest {
830
+ every {
831
+ PurchasingService.notifyFulfillment(any(), any())
832
+ } returns Unit
833
+
834
+ backend.finishTransaction("rcpt-001")
835
+
836
+ verify {
837
+ PurchasingService.notifyFulfillment("rcpt-001", FulfillmentResult.FULFILLED)
838
+ }
839
+ }
840
+
841
+ // ── receiptToDict ─────────────────────────────────────────────────
842
+
843
+ @Test
844
+ fun `receiptToDict maps all expected keys`() {
845
+ val receipt =
846
+ mockReceipt(
847
+ receiptId = "rcpt-map-001",
848
+ sku = "sku-mapped",
849
+ purchaseDate = Date(1_700_000_500_000L),
850
+ )
851
+ val dict = backend.receiptToDict(receipt)
852
+
853
+ assertEquals("sku-mapped", dict["skuId"])
854
+ assertEquals("rcpt-map-001", dict["transactionId"])
855
+ assertEquals("rcpt-map-001", dict["originalTransactionId"])
856
+ assertEquals("rcpt-map-001", dict["receipt"])
857
+ assertEquals("rcpt-map-001", dict["purchaseToken"])
858
+ assertEquals(1_700_000_500_000L, dict["purchaseTime"])
859
+ }
860
+
861
+ // ── Receiver mode ────────────────────────────────────────────────
862
+
863
+ @Test
864
+ fun `receiver mode - initConnection does NOT call registerListener`() =
865
+ runTest {
866
+ ExpoNamiIapAmazon.setAutoRegisterListener(false)
867
+
868
+ backend.initConnection(mockContext)
869
+
870
+ verify(exactly = 0) { PurchasingService.registerListener(any(), any()) }
871
+ }
872
+
873
+ @Test
874
+ fun `receiver mode - ExpoNamiIapAmazonReceiver forwards onProductDataResponse`() =
875
+ runTest {
876
+ val testDispatcher = StandardTestDispatcher(testScheduler)
877
+ val testBackend = AmazonIapBackend(scope = CoroutineScope(testDispatcher))
878
+
879
+ ExpoNamiIapAmazon.setAutoRegisterListener(false)
880
+ testBackend.setContextForTesting(mockContext)
881
+
882
+ val reqId = mockRequestId("recv-req-001")
883
+ every { PurchasingService.getProductData(any()) } returns reqId
884
+
885
+ val product = mockProduct(sku = "sku-recv", price = "$4.99")
886
+
887
+ val resultDeferred =
888
+ kotlinx.coroutines.CompletableDeferred<List<Map<String, Any?>>>()
889
+ val job =
890
+ launch {
891
+ try {
892
+ resultDeferred.complete(testBackend.getProducts(listOf("sku-recv")))
893
+ } catch (e: Exception) {
894
+ resultDeferred.completeExceptionally(e)
895
+ }
896
+ }
897
+ advanceUntilIdle()
898
+
899
+ // Verify registerListener was NOT called
900
+ verify(exactly = 0) { PurchasingService.registerListener(any(), any()) }
901
+
902
+ // Host app forwards the response via the receiver
903
+ val response =
904
+ mockProductDataResponse(
905
+ requestId = reqId,
906
+ status = ProductDataResponse.RequestStatus.SUCCESSFUL,
907
+ products = mapOf("sku-recv" to product),
908
+ )
909
+ ExpoNamiIapAmazonReceiver.onProductDataResponse(response)
910
+
911
+ val result = resultDeferred.await()
912
+ assertEquals(1, result.size)
913
+ assertEquals("sku-recv", result[0]["product_ref_id"])
914
+ job.cancel()
915
+ }
916
+
917
+ @Test
918
+ fun `receiver mode - pending request times out after 30s with diagnostic error`() =
919
+ runTest {
920
+ val testDispatcher = StandardTestDispatcher(testScheduler)
921
+ val testBackend = AmazonIapBackend(scope = CoroutineScope(testDispatcher))
922
+
923
+ ExpoNamiIapAmazon.setAutoRegisterListener(false)
924
+ testBackend.setContextForTesting(mockContext)
925
+
926
+ val reqId = mockRequestId("recv-timeout-001")
927
+ every { PurchasingService.getProductData(any()) } returns reqId
928
+
929
+ val resultDeferred =
930
+ kotlinx.coroutines.CompletableDeferred<List<Map<String, Any?>>>()
931
+ val job =
932
+ launch {
933
+ try {
934
+ resultDeferred.complete(testBackend.getProducts(listOf("sku-timeout")))
935
+ } catch (e: Exception) {
936
+ resultDeferred.completeExceptionally(e)
937
+ }
938
+ }
939
+ advanceUntilIdle()
940
+
941
+ // Advance time past the 30s timeout
942
+ advanceTimeBy(AmazonIapBackend.TIMEOUT_MS + 1)
943
+ advanceUntilIdle()
944
+
945
+ try {
946
+ resultDeferred.await()
947
+ fail("Expected timeout exception to be thrown")
948
+ } catch (e: ExpoNamiIapException.Unknown) {
949
+ assertTrue(
950
+ "Timeout message should mention receiver mode",
951
+ e.message!!.contains("receiver mode"),
952
+ )
953
+ assertTrue(
954
+ "Timeout message should mention ExpoNamiIapAmazonReceiver",
955
+ e.message!!.contains("ExpoNamiIapAmazonReceiver"),
956
+ )
957
+ }
958
+ job.cancel()
959
+ }
960
+
961
+ @Test
962
+ fun `auto mode regression - initConnection registers listener as before`() =
963
+ runTest {
964
+ // autoRegister is true by default (reset in setUp)
965
+ assertTrue(
966
+ "autoRegisterEnabled should default to true",
967
+ ExpoNamiIapAmazon.autoRegisterEnabled(),
968
+ )
969
+
970
+ backend.initConnection(mockContext)
971
+
972
+ verify(exactly = 1) { PurchasingService.registerListener(mockContext, any()) }
973
+ }
974
+ }