@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,184 @@
|
|
|
1
|
+
package ml.nami.expo.iap
|
|
2
|
+
|
|
3
|
+
import com.android.billingclient.api.BillingClient.BillingResponseCode
|
|
4
|
+
import org.junit.Assert.assertEquals
|
|
5
|
+
import org.junit.Assert.assertTrue
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
class ErrorsTest {
|
|
9
|
+
// ── billingResponseToCode ───────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
@Test
|
|
12
|
+
fun `USER_CANCELED maps to user_cancelled`() {
|
|
13
|
+
assertEquals("user_cancelled", Errors.billingResponseToCode(BillingResponseCode.USER_CANCELED))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Test
|
|
17
|
+
fun `SERVICE_DISCONNECTED maps to network`() {
|
|
18
|
+
assertEquals("network", Errors.billingResponseToCode(BillingResponseCode.SERVICE_DISCONNECTED))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test
|
|
22
|
+
fun `SERVICE_UNAVAILABLE maps to network`() {
|
|
23
|
+
assertEquals("network", Errors.billingResponseToCode(BillingResponseCode.SERVICE_UNAVAILABLE))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test
|
|
27
|
+
fun `ITEM_UNAVAILABLE maps to item_unavailable`() {
|
|
28
|
+
assertEquals("item_unavailable", Errors.billingResponseToCode(BillingResponseCode.ITEM_UNAVAILABLE))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test
|
|
32
|
+
fun `ITEM_ALREADY_OWNED maps to already_owned`() {
|
|
33
|
+
assertEquals("already_owned", Errors.billingResponseToCode(BillingResponseCode.ITEM_ALREADY_OWNED))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Test
|
|
37
|
+
fun `FEATURE_NOT_SUPPORTED maps to not_allowed`() {
|
|
38
|
+
assertEquals("not_allowed", Errors.billingResponseToCode(BillingResponseCode.FEATURE_NOT_SUPPORTED))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Test
|
|
42
|
+
fun `DEVELOPER_ERROR maps to not_allowed`() {
|
|
43
|
+
assertEquals("not_allowed", Errors.billingResponseToCode(BillingResponseCode.DEVELOPER_ERROR))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Test
|
|
47
|
+
fun `BILLING_UNAVAILABLE maps to not_allowed`() {
|
|
48
|
+
assertEquals("not_allowed", Errors.billingResponseToCode(BillingResponseCode.BILLING_UNAVAILABLE))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Test
|
|
52
|
+
fun `ERROR maps to unknown`() {
|
|
53
|
+
assertEquals("unknown", Errors.billingResponseToCode(BillingResponseCode.ERROR))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Test
|
|
57
|
+
fun `ITEM_NOT_OWNED maps to unknown`() {
|
|
58
|
+
assertEquals("unknown", Errors.billingResponseToCode(BillingResponseCode.ITEM_NOT_OWNED))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test
|
|
62
|
+
fun `unrecognized code maps to unknown`() {
|
|
63
|
+
assertEquals("unknown", Errors.billingResponseToCode(9999))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── billingResponseToNativeCode ─────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
@Test
|
|
69
|
+
fun `USER_CANCELED native code is USER_CANCELED`() {
|
|
70
|
+
assertEquals("USER_CANCELED", Errors.billingResponseToNativeCode(BillingResponseCode.USER_CANCELED))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Test
|
|
74
|
+
fun `ITEM_UNAVAILABLE native code is ITEM_UNAVAILABLE`() {
|
|
75
|
+
assertEquals("ITEM_UNAVAILABLE", Errors.billingResponseToNativeCode(BillingResponseCode.ITEM_UNAVAILABLE))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@Test
|
|
79
|
+
fun `SERVICE_DISCONNECTED native code is SERVICE_DISCONNECTED`() {
|
|
80
|
+
assertEquals(
|
|
81
|
+
"SERVICE_DISCONNECTED",
|
|
82
|
+
Errors.billingResponseToNativeCode(BillingResponseCode.SERVICE_DISCONNECTED),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@Test
|
|
87
|
+
fun `OK native code is OK`() {
|
|
88
|
+
assertEquals("OK", Errors.billingResponseToNativeCode(BillingResponseCode.OK))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@Test
|
|
92
|
+
fun `NETWORK_ERROR native code is NETWORK_ERROR`() {
|
|
93
|
+
assertEquals("NETWORK_ERROR", Errors.billingResponseToNativeCode(BillingResponseCode.NETWORK_ERROR))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@Test
|
|
97
|
+
fun `unrecognized code returns UNKNOWN(code)`() {
|
|
98
|
+
assertEquals("UNKNOWN(9999)", Errors.billingResponseToNativeCode(9999))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── toException ─────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
@Test
|
|
104
|
+
fun `toException USER_CANCELED returns UserCancelled`() {
|
|
105
|
+
val ex = Errors.toException(BillingResponseCode.USER_CANCELED)
|
|
106
|
+
assertTrue(ex is ExpoNamiIapException.UserCancelled)
|
|
107
|
+
assertEquals("user_cancelled", ex.code)
|
|
108
|
+
assertEquals("USER_CANCELED", ex.nativeCode)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Test
|
|
112
|
+
fun `toException SERVICE_DISCONNECTED returns Network`() {
|
|
113
|
+
val ex = Errors.toException(BillingResponseCode.SERVICE_DISCONNECTED, "Service lost")
|
|
114
|
+
assertTrue(ex is ExpoNamiIapException.Network)
|
|
115
|
+
assertEquals("network", ex.code)
|
|
116
|
+
assertEquals("Service lost", ex.message)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@Test
|
|
120
|
+
fun `toException ITEM_UNAVAILABLE returns ItemUnavailable`() {
|
|
121
|
+
val ex = Errors.toException(BillingResponseCode.ITEM_UNAVAILABLE)
|
|
122
|
+
assertTrue(ex is ExpoNamiIapException.ItemUnavailable)
|
|
123
|
+
assertEquals("item_unavailable", ex.code)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@Test
|
|
127
|
+
fun `toException ITEM_ALREADY_OWNED returns AlreadyOwned`() {
|
|
128
|
+
val ex = Errors.toException(BillingResponseCode.ITEM_ALREADY_OWNED)
|
|
129
|
+
assertTrue(ex is ExpoNamiIapException.AlreadyOwned)
|
|
130
|
+
assertEquals("already_owned", ex.code)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@Test
|
|
134
|
+
fun `toException DEVELOPER_ERROR returns NotAllowed`() {
|
|
135
|
+
val ex = Errors.toException(BillingResponseCode.DEVELOPER_ERROR)
|
|
136
|
+
assertTrue(ex is ExpoNamiIapException.NotAllowed)
|
|
137
|
+
assertEquals("not_allowed", ex.code)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Test
|
|
141
|
+
fun `toException ERROR returns Unknown`() {
|
|
142
|
+
val ex = Errors.toException(BillingResponseCode.ERROR, "Fatal error")
|
|
143
|
+
assertTrue(ex is ExpoNamiIapException.Unknown)
|
|
144
|
+
assertEquals("unknown", ex.code)
|
|
145
|
+
assertEquals("Fatal error", ex.message)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Complete BillingResponseCode coverage ───────────────────────────
|
|
149
|
+
|
|
150
|
+
@Test
|
|
151
|
+
fun `all known BillingResponseCode values are mapped`() {
|
|
152
|
+
val allCodes =
|
|
153
|
+
listOf(
|
|
154
|
+
BillingResponseCode.SERVICE_TIMEOUT,
|
|
155
|
+
BillingResponseCode.FEATURE_NOT_SUPPORTED,
|
|
156
|
+
BillingResponseCode.SERVICE_DISCONNECTED,
|
|
157
|
+
BillingResponseCode.OK,
|
|
158
|
+
BillingResponseCode.USER_CANCELED,
|
|
159
|
+
BillingResponseCode.SERVICE_UNAVAILABLE,
|
|
160
|
+
BillingResponseCode.BILLING_UNAVAILABLE,
|
|
161
|
+
BillingResponseCode.ITEM_UNAVAILABLE,
|
|
162
|
+
BillingResponseCode.DEVELOPER_ERROR,
|
|
163
|
+
BillingResponseCode.ERROR,
|
|
164
|
+
BillingResponseCode.ITEM_ALREADY_OWNED,
|
|
165
|
+
BillingResponseCode.ITEM_NOT_OWNED,
|
|
166
|
+
BillingResponseCode.NETWORK_ERROR,
|
|
167
|
+
)
|
|
168
|
+
for (code in allCodes) {
|
|
169
|
+
val normalized = Errors.billingResponseToCode(code)
|
|
170
|
+
assertTrue(
|
|
171
|
+
"Code $code should map to a known normalized value, got: $normalized",
|
|
172
|
+
normalized in
|
|
173
|
+
setOf(
|
|
174
|
+
"user_cancelled",
|
|
175
|
+
"network",
|
|
176
|
+
"item_unavailable",
|
|
177
|
+
"already_owned",
|
|
178
|
+
"not_allowed",
|
|
179
|
+
"unknown",
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
package ml.nami.expo.iap
|
|
2
|
+
|
|
3
|
+
import ml.nami.expo.iap.google.PlayBillingBackend
|
|
4
|
+
import org.junit.Assert.assertEquals
|
|
5
|
+
import org.junit.Assert.assertNotNull
|
|
6
|
+
import org.junit.Assert.assertTrue
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Smoke tests for [ExpoNamiIapModule] backend resolution logic.
|
|
11
|
+
*
|
|
12
|
+
* These tests verify the classpath-based backend selection without
|
|
13
|
+
* instantiating the full Expo Module (which requires the Expo runtime).
|
|
14
|
+
* Under the `google` flavor PlayBillingBackend is on the classpath,
|
|
15
|
+
* so [buildBackend] must resolve to it.
|
|
16
|
+
*/
|
|
17
|
+
class ExpoNamiIapModuleTest {
|
|
18
|
+
/**
|
|
19
|
+
* Verify that [PlayBillingBackend] is discoverable on the classpath
|
|
20
|
+
* via the same Class.forName call used by [ExpoNamiIapModule.buildBackend].
|
|
21
|
+
*/
|
|
22
|
+
@Test
|
|
23
|
+
fun `buildBackend resolves PlayBillingBackend under google flavor`() {
|
|
24
|
+
val cls = Class.forName("ml.nami.expo.iap.google.PlayBillingBackend")
|
|
25
|
+
assertNotNull("PlayBillingBackend class should be on the classpath", cls)
|
|
26
|
+
|
|
27
|
+
val instance = cls.getDeclaredConstructor().newInstance()
|
|
28
|
+
assertTrue(
|
|
29
|
+
"Resolved backend should be an IapBackend",
|
|
30
|
+
instance is IapBackend,
|
|
31
|
+
)
|
|
32
|
+
assertEquals(
|
|
33
|
+
"Resolved backend should be PlayBillingBackend",
|
|
34
|
+
PlayBillingBackend::class.java,
|
|
35
|
+
instance::class.java,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Verify that the Google flavor classpath does NOT contain AmazonIapBackend,
|
|
41
|
+
* confirming that the flavor-based resolution logic would fall through to
|
|
42
|
+
* the Google path first.
|
|
43
|
+
*/
|
|
44
|
+
@Test
|
|
45
|
+
fun `google flavor prefers PlayBillingBackend over AmazonIapBackend`() {
|
|
46
|
+
// PlayBillingBackend should be found first (no ClassNotFoundException)
|
|
47
|
+
val googleCls =
|
|
48
|
+
try {
|
|
49
|
+
Class.forName("ml.nami.expo.iap.google.PlayBillingBackend")
|
|
50
|
+
} catch (_: ClassNotFoundException) {
|
|
51
|
+
null
|
|
52
|
+
}
|
|
53
|
+
assertNotNull("PlayBillingBackend should be on the classpath", googleCls)
|
|
54
|
+
|
|
55
|
+
// Replicate the buildBackend resolution logic
|
|
56
|
+
val resolvedCls =
|
|
57
|
+
try {
|
|
58
|
+
Class.forName("ml.nami.expo.iap.google.PlayBillingBackend")
|
|
59
|
+
} catch (_: ClassNotFoundException) {
|
|
60
|
+
Class.forName("ml.nami.expo.iap.amazon.AmazonIapBackend")
|
|
61
|
+
}
|
|
62
|
+
val backend = resolvedCls.getDeclaredConstructor().newInstance() as IapBackend
|
|
63
|
+
assertTrue(
|
|
64
|
+
"Resolved backend should be PlayBillingBackend",
|
|
65
|
+
backend is PlayBillingBackend,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Verify that [PlayBillingBackend] correctly implements callback
|
|
71
|
+
* registration methods from [IapBackend].
|
|
72
|
+
*/
|
|
73
|
+
@Test
|
|
74
|
+
fun `PlayBillingBackend supports onTransactionUpdate and onError callbacks`() {
|
|
75
|
+
val backend = PlayBillingBackend()
|
|
76
|
+
var transactionReceived = false
|
|
77
|
+
var errorReceived = false
|
|
78
|
+
|
|
79
|
+
backend.onTransactionUpdate { transactionReceived = true }
|
|
80
|
+
backend.onError { errorReceived = true }
|
|
81
|
+
|
|
82
|
+
// Callbacks are stored; they will be invoked by PurchasesUpdatedListener
|
|
83
|
+
// This test verifies no exception is thrown during registration
|
|
84
|
+
assertNotNull("Backend should be non-null after callback registration", backend)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
package ml.nami.expo.iap
|
|
2
|
+
|
|
3
|
+
import com.android.billingclient.api.BillingClient.ProductType
|
|
4
|
+
import com.android.billingclient.api.ProductDetails
|
|
5
|
+
import io.mockk.every
|
|
6
|
+
import io.mockk.mockk
|
|
7
|
+
import org.junit.Assert.assertEquals
|
|
8
|
+
import org.junit.Assert.assertNotNull
|
|
9
|
+
import org.junit.Assert.assertNull
|
|
10
|
+
import org.junit.Test
|
|
11
|
+
|
|
12
|
+
class ProductMapperTest {
|
|
13
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
private fun mockOneTimePurchaseOfferDetails(
|
|
16
|
+
priceMicros: Long = 4_990_000L,
|
|
17
|
+
currency: String = "USD",
|
|
18
|
+
formattedPrice: String = "$4.99",
|
|
19
|
+
): ProductDetails.OneTimePurchaseOfferDetails {
|
|
20
|
+
val otp = mockk<ProductDetails.OneTimePurchaseOfferDetails>()
|
|
21
|
+
every { otp.priceAmountMicros } returns priceMicros
|
|
22
|
+
every { otp.priceCurrencyCode } returns currency
|
|
23
|
+
every { otp.formattedPrice } returns formattedPrice
|
|
24
|
+
return otp
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private fun mockPricingPhase(
|
|
28
|
+
priceMicros: Long,
|
|
29
|
+
currency: String = "USD",
|
|
30
|
+
billingPeriod: String = "P1M",
|
|
31
|
+
billingCycleCount: Int = 0,
|
|
32
|
+
recurrenceMode: Int = 1,
|
|
33
|
+
): ProductDetails.PricingPhase {
|
|
34
|
+
val phase = mockk<ProductDetails.PricingPhase>()
|
|
35
|
+
every { phase.priceAmountMicros } returns priceMicros
|
|
36
|
+
every { phase.priceCurrencyCode } returns currency
|
|
37
|
+
every { phase.billingPeriod } returns billingPeriod
|
|
38
|
+
every { phase.billingCycleCount } returns billingCycleCount
|
|
39
|
+
every { phase.recurrenceMode } returns recurrenceMode
|
|
40
|
+
every { phase.formattedPrice } returns "$${priceMicros / 1_000_000.0}"
|
|
41
|
+
return phase
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private fun mockPricingPhases(phases: List<ProductDetails.PricingPhase>): ProductDetails.PricingPhases {
|
|
45
|
+
val pp = mockk<ProductDetails.PricingPhases>()
|
|
46
|
+
every { pp.pricingPhaseList } returns phases
|
|
47
|
+
return pp
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private fun mockSubscriptionOfferDetails(
|
|
51
|
+
basePlanId: String = "base",
|
|
52
|
+
offerId: String? = null,
|
|
53
|
+
phases: List<ProductDetails.PricingPhase>,
|
|
54
|
+
offerTags: List<String> = emptyList(),
|
|
55
|
+
offerToken: String = "token-$basePlanId-${offerId ?: "base"}",
|
|
56
|
+
): ProductDetails.SubscriptionOfferDetails {
|
|
57
|
+
val sod = mockk<ProductDetails.SubscriptionOfferDetails>()
|
|
58
|
+
every { sod.basePlanId } returns basePlanId
|
|
59
|
+
every { sod.offerId } returns offerId
|
|
60
|
+
every { sod.pricingPhases } returns mockPricingPhases(phases)
|
|
61
|
+
every { sod.offerTags } returns offerTags
|
|
62
|
+
every { sod.offerToken } returns offerToken
|
|
63
|
+
return sod
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private fun mockInAppProduct(
|
|
67
|
+
productId: String = "com.nami.test.consumable",
|
|
68
|
+
name: String = "100 Coins",
|
|
69
|
+
description: String = "Pack of 100 coins",
|
|
70
|
+
priceMicros: Long = 4_990_000L,
|
|
71
|
+
currency: String = "USD",
|
|
72
|
+
): ProductDetails {
|
|
73
|
+
val pd = mockk<ProductDetails>()
|
|
74
|
+
every { pd.productId } returns productId
|
|
75
|
+
every { pd.name } returns name
|
|
76
|
+
every { pd.description } returns description
|
|
77
|
+
every { pd.productType } returns ProductType.INAPP
|
|
78
|
+
every { pd.oneTimePurchaseOfferDetails } returns
|
|
79
|
+
mockOneTimePurchaseOfferDetails(
|
|
80
|
+
priceMicros = priceMicros,
|
|
81
|
+
currency = currency,
|
|
82
|
+
)
|
|
83
|
+
every { pd.subscriptionOfferDetails } returns null
|
|
84
|
+
return pd
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private fun mockSubscriptionProduct(
|
|
88
|
+
productId: String = "com.nami.test.monthly",
|
|
89
|
+
name: String = "Monthly Premium",
|
|
90
|
+
description: String = "Premium monthly subscription",
|
|
91
|
+
basePriceMicros: Long = 9_990_000L,
|
|
92
|
+
currency: String = "USD",
|
|
93
|
+
billingPeriod: String = "P1M",
|
|
94
|
+
extraOffers: List<ProductDetails.SubscriptionOfferDetails> = emptyList(),
|
|
95
|
+
): ProductDetails {
|
|
96
|
+
val basePlan =
|
|
97
|
+
mockSubscriptionOfferDetails(
|
|
98
|
+
phases =
|
|
99
|
+
listOf(
|
|
100
|
+
mockPricingPhase(
|
|
101
|
+
priceMicros = basePriceMicros,
|
|
102
|
+
currency = currency,
|
|
103
|
+
billingPeriod = billingPeriod,
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
val pd = mockk<ProductDetails>()
|
|
109
|
+
every { pd.productId } returns productId
|
|
110
|
+
every { pd.name } returns name
|
|
111
|
+
every { pd.description } returns description
|
|
112
|
+
every { pd.productType } returns ProductType.SUBS
|
|
113
|
+
every { pd.oneTimePurchaseOfferDetails } returns null
|
|
114
|
+
every { pd.subscriptionOfferDetails } returns listOf(basePlan) + extraOffers
|
|
115
|
+
return pd
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Consumable / INAPP ──────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
@Test
|
|
121
|
+
fun `consumable maps to one_time_purchase with default offer`() {
|
|
122
|
+
val pd = mockInAppProduct()
|
|
123
|
+
val result = ProductMapper.toMap(pd)
|
|
124
|
+
|
|
125
|
+
assertEquals("com.nami.test.consumable", result["product_ref_id"])
|
|
126
|
+
assertEquals("one_time_purchase", result["product_type"])
|
|
127
|
+
assertEquals("100 Coins", result["name"])
|
|
128
|
+
assertEquals("Pack of 100 coins", result["description"])
|
|
129
|
+
assertNull("Consumable must not have subscription_period", result["subscription_period"])
|
|
130
|
+
|
|
131
|
+
@Suppress("UNCHECKED_CAST")
|
|
132
|
+
val offers = result["offers"] as List<Map<String, Any?>>
|
|
133
|
+
assertEquals(1, offers.size)
|
|
134
|
+
assertEquals("default", offers[0]["offer_type"])
|
|
135
|
+
assertEquals(4.99, offers[0]["price"] as Double, 0.001)
|
|
136
|
+
assertEquals("USD", offers[0]["currency"])
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Subscription with single base plan (monthly, $9.99) ─────────────
|
|
140
|
+
|
|
141
|
+
@Test
|
|
142
|
+
fun `subscription with monthly base plan maps correctly`() {
|
|
143
|
+
val pd = mockSubscriptionProduct()
|
|
144
|
+
val result = ProductMapper.toMap(pd)
|
|
145
|
+
|
|
146
|
+
assertEquals("com.nami.test.monthly", result["product_ref_id"])
|
|
147
|
+
assertEquals("subscription", result["product_type"])
|
|
148
|
+
|
|
149
|
+
@Suppress("UNCHECKED_CAST")
|
|
150
|
+
val period = result["subscription_period"] as Map<String, Any>
|
|
151
|
+
assertEquals("month", period["type"])
|
|
152
|
+
assertEquals(1, period["count"])
|
|
153
|
+
|
|
154
|
+
@Suppress("UNCHECKED_CAST")
|
|
155
|
+
val offers = result["offers"] as List<Map<String, Any?>>
|
|
156
|
+
val defaultOffer = offers.first { it["offer_type"] == "default" }
|
|
157
|
+
assertEquals(9.99, defaultOffer["price"] as Double, 0.001)
|
|
158
|
+
assertEquals("USD", defaultOffer["currency"])
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Subscription with free-trial offer ──────────────────────────────
|
|
162
|
+
|
|
163
|
+
@Test
|
|
164
|
+
fun `subscription with free trial offer appends free_trial`() {
|
|
165
|
+
val trialOffer =
|
|
166
|
+
mockSubscriptionOfferDetails(
|
|
167
|
+
offerId = "free-trial-7d",
|
|
168
|
+
phases =
|
|
169
|
+
listOf(
|
|
170
|
+
mockPricingPhase(priceMicros = 0L, billingPeriod = "P1W"),
|
|
171
|
+
mockPricingPhase(priceMicros = 9_990_000L, billingPeriod = "P1M"),
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
val pd =
|
|
176
|
+
mockSubscriptionProduct(
|
|
177
|
+
extraOffers = listOf(trialOffer),
|
|
178
|
+
)
|
|
179
|
+
val result = ProductMapper.toMap(pd)
|
|
180
|
+
|
|
181
|
+
@Suppress("UNCHECKED_CAST")
|
|
182
|
+
val offers = result["offers"] as List<Map<String, Any?>>
|
|
183
|
+
val freeTrialOffer = offers.firstOrNull { it["offer_type"] == "free_trial" }
|
|
184
|
+
assertNotNull("Should have a free_trial offer", freeTrialOffer)
|
|
185
|
+
assertEquals(0.0, freeTrialOffer!!["price"] as Double, 0.001)
|
|
186
|
+
assertEquals("free-trial-7d", freeTrialOffer["offer_ref_id"])
|
|
187
|
+
|
|
188
|
+
@Suppress("UNCHECKED_CAST")
|
|
189
|
+
val promoPeriod = freeTrialOffer["promo_period"] as Map<String, Any>
|
|
190
|
+
assertEquals("week", promoPeriod["type"])
|
|
191
|
+
assertEquals(1, promoPeriod["count"])
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Subscription with intro price offer ─────────────────────────────
|
|
195
|
+
|
|
196
|
+
@Test
|
|
197
|
+
fun `subscription with intro price offer appends intro`() {
|
|
198
|
+
val introOffer =
|
|
199
|
+
mockSubscriptionOfferDetails(
|
|
200
|
+
offerId = "intro-monthly",
|
|
201
|
+
phases =
|
|
202
|
+
listOf(
|
|
203
|
+
mockPricingPhase(priceMicros = 4_990_000L, billingPeriod = "P1M", billingCycleCount = 3),
|
|
204
|
+
mockPricingPhase(priceMicros = 9_990_000L, billingPeriod = "P1M"),
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
val pd =
|
|
209
|
+
mockSubscriptionProduct(
|
|
210
|
+
extraOffers = listOf(introOffer),
|
|
211
|
+
)
|
|
212
|
+
val result = ProductMapper.toMap(pd)
|
|
213
|
+
|
|
214
|
+
@Suppress("UNCHECKED_CAST")
|
|
215
|
+
val offers = result["offers"] as List<Map<String, Any?>>
|
|
216
|
+
val introOfferResult = offers.firstOrNull { it["offer_type"] == "intro" }
|
|
217
|
+
assertNotNull("Should have an intro offer", introOfferResult)
|
|
218
|
+
assertEquals(4.99, introOfferResult!!["price"] as Double, 0.001)
|
|
219
|
+
assertEquals("intro-monthly", introOfferResult["offer_ref_id"])
|
|
220
|
+
|
|
221
|
+
@Suppress("UNCHECKED_CAST")
|
|
222
|
+
val promoPeriod = introOfferResult["promo_period"] as Map<String, Any>
|
|
223
|
+
assertEquals("month", promoPeriod["type"])
|
|
224
|
+
assertEquals(1, promoPeriod["count"])
|
|
225
|
+
|
|
226
|
+
@Suppress("UNCHECKED_CAST")
|
|
227
|
+
val promoPrice = introOfferResult["promo_price"] as Map<String, Any>
|
|
228
|
+
assertEquals(4.99, promoPrice["price"] as Double, 0.001)
|
|
229
|
+
assertEquals("USD", promoPrice["currency"])
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Period parsing ──────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
@Test
|
|
235
|
+
fun `P1D parses to day 1`() {
|
|
236
|
+
val p = ProductMapper.parsePeriod("P1D")
|
|
237
|
+
assertEquals("day", p["type"])
|
|
238
|
+
assertEquals(1, p["count"])
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
@Test
|
|
242
|
+
fun `P1W parses to week 1`() {
|
|
243
|
+
val p = ProductMapper.parsePeriod("P1W")
|
|
244
|
+
assertEquals("week", p["type"])
|
|
245
|
+
assertEquals(1, p["count"])
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
@Test
|
|
249
|
+
fun `P1M parses to month 1`() {
|
|
250
|
+
val p = ProductMapper.parsePeriod("P1M")
|
|
251
|
+
assertEquals("month", p["type"])
|
|
252
|
+
assertEquals(1, p["count"])
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@Test
|
|
256
|
+
fun `P3M parses to month 3`() {
|
|
257
|
+
val p = ProductMapper.parsePeriod("P3M")
|
|
258
|
+
assertEquals("month", p["type"])
|
|
259
|
+
assertEquals(3, p["count"])
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@Test
|
|
263
|
+
fun `P1Y parses to year 1`() {
|
|
264
|
+
val p = ProductMapper.parsePeriod("P1Y")
|
|
265
|
+
assertEquals("year", p["type"])
|
|
266
|
+
assertEquals(1, p["count"])
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
@Test
|
|
270
|
+
fun `invalid period falls back to unknown 0`() {
|
|
271
|
+
val p = ProductMapper.parsePeriod("INVALID")
|
|
272
|
+
assertEquals("unknown", p["type"])
|
|
273
|
+
assertEquals(0, p["count"])
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── All required keys present ───────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
@Test
|
|
279
|
+
fun `subscription result contains all required NamiProductDetails keys`() {
|
|
280
|
+
val pd = mockSubscriptionProduct()
|
|
281
|
+
val result = ProductMapper.toMap(pd)
|
|
282
|
+
|
|
283
|
+
assertNotNull(result["product_ref_id"])
|
|
284
|
+
assertNotNull(result["product_type"])
|
|
285
|
+
assertNotNull(result["offers"])
|
|
286
|
+
assertNotNull(result["name"])
|
|
287
|
+
assertNotNull(result["description"])
|
|
288
|
+
assertNotNull(result["subscription_period"])
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
@Test
|
|
292
|
+
fun `consumable has exactly one default offer`() {
|
|
293
|
+
val pd = mockInAppProduct()
|
|
294
|
+
val result = ProductMapper.toMap(pd)
|
|
295
|
+
|
|
296
|
+
@Suppress("UNCHECKED_CAST")
|
|
297
|
+
val offers = result["offers"] as List<Map<String, Any?>>
|
|
298
|
+
assertEquals(1, offers.size)
|
|
299
|
+
assertEquals("default", offers[0]["offer_type"])
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Yearly subscription ─────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
@Test
|
|
305
|
+
fun `yearly subscription has correct period`() {
|
|
306
|
+
val pd =
|
|
307
|
+
mockSubscriptionProduct(
|
|
308
|
+
productId = "com.nami.test.yearly",
|
|
309
|
+
billingPeriod = "P1Y",
|
|
310
|
+
)
|
|
311
|
+
val result = ProductMapper.toMap(pd)
|
|
312
|
+
|
|
313
|
+
@Suppress("UNCHECKED_CAST")
|
|
314
|
+
val period = result["subscription_period"] as Map<String, Any>
|
|
315
|
+
assertEquals("year", period["type"])
|
|
316
|
+
assertEquals(1, period["count"])
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Weekly subscription ─────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
@Test
|
|
322
|
+
fun `weekly subscription has correct period`() {
|
|
323
|
+
val pd =
|
|
324
|
+
mockSubscriptionProduct(
|
|
325
|
+
productId = "com.nami.test.weekly",
|
|
326
|
+
billingPeriod = "P1W",
|
|
327
|
+
)
|
|
328
|
+
val result = ProductMapper.toMap(pd)
|
|
329
|
+
|
|
330
|
+
@Suppress("UNCHECKED_CAST")
|
|
331
|
+
val period = result["subscription_period"] as Map<String, Any>
|
|
332
|
+
assertEquals("week", period["type"])
|
|
333
|
+
assertEquals(1, period["count"])
|
|
334
|
+
}
|
|
335
|
+
}
|