@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.
- package/README.md +121 -0
- package/android/build.gradle +46 -0
- package/android/gradle.properties +1 -0
- package/android/libs/.gitkeep +0 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackend.kt +427 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonProductMapper.kt +119 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonPurchasingListener.kt +35 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazon.kt +46 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazonReceiver.kt +48 -0
- package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingBackend.kt +446 -0
- package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingConnection.kt +104 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ml/nami/expo/iap/Errors.kt +94 -0
- package/android/src/main/java/ml/nami/expo/iap/ExpoNamiIapModule.kt +56 -0
- package/android/src/main/java/ml/nami/expo/iap/IapBackend.kt +72 -0
- package/android/src/main/java/ml/nami/expo/iap/ProductMapper.kt +191 -0
- package/android/src/test/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackendTest.kt +974 -0
- package/android/src/test/java/ml/nami/expo/iap/ErrorsTest.kt +184 -0
- package/android/src/test/java/ml/nami/expo/iap/ExpoNamiIapModuleTest.kt +86 -0
- package/android/src/test/java/ml/nami/expo/iap/PlaceholderTest.kt +8 -0
- package/android/src/test/java/ml/nami/expo/iap/ProductMapperTest.kt +335 -0
- package/android/src/test/java/ml/nami/expo/iap/google/PlayBillingBackendTest.kt +841 -0
- package/android/src/testAmazon/java/ml/nami/expo/iap/ExpoNamiIapModuleAmazonFlavorTest.kt +71 -0
- package/dist/index.cjs +56 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.mjs +45 -0
- package/expo-module.config.json +9 -0
- package/ios/Errors.swift +178 -0
- package/ios/ExpoNamiIap.podspec +14 -0
- package/ios/ExpoNamiIapModule.swift +81 -0
- package/ios/ProductMapper.swift +130 -0
- package/ios/PromotionalOfferSigner.swift +78 -0
- package/ios/StoreKit2Helper.swift +252 -0
- package/ios/Tests/ErrorsTests.swift +189 -0
- package/ios/Tests/ProductMapperTests.swift +165 -0
- package/ios/Tests/Resources/Products.storekit +210 -0
- package/ios/Tests/StoreKit2HelperTests.swift +360 -0
- package/ios/TransactionObserver.swift +54 -0
- package/package.json +64 -0
- package/src/ExpoNamiIapModule.kepler.ts +200 -0
- package/src/ExpoNamiIapModule.ts +18 -0
- package/src/index.ts +63 -0
- 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
|
+
}
|