@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,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
|
+
}
|