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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +121 -0
  2. package/android/build.gradle +46 -0
  3. package/android/gradle.properties +1 -0
  4. package/android/libs/.gitkeep +0 -0
  5. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackend.kt +427 -0
  6. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonProductMapper.kt +119 -0
  7. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonPurchasingListener.kt +35 -0
  8. package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazon.kt +46 -0
  9. package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazonReceiver.kt +48 -0
  10. package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingBackend.kt +446 -0
  11. package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingConnection.kt +104 -0
  12. package/android/src/main/AndroidManifest.xml +1 -0
  13. package/android/src/main/java/ml/nami/expo/iap/Errors.kt +94 -0
  14. package/android/src/main/java/ml/nami/expo/iap/ExpoNamiIapModule.kt +56 -0
  15. package/android/src/main/java/ml/nami/expo/iap/IapBackend.kt +72 -0
  16. package/android/src/main/java/ml/nami/expo/iap/ProductMapper.kt +191 -0
  17. package/android/src/test/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackendTest.kt +974 -0
  18. package/android/src/test/java/ml/nami/expo/iap/ErrorsTest.kt +184 -0
  19. package/android/src/test/java/ml/nami/expo/iap/ExpoNamiIapModuleTest.kt +86 -0
  20. package/android/src/test/java/ml/nami/expo/iap/PlaceholderTest.kt +8 -0
  21. package/android/src/test/java/ml/nami/expo/iap/ProductMapperTest.kt +335 -0
  22. package/android/src/test/java/ml/nami/expo/iap/google/PlayBillingBackendTest.kt +841 -0
  23. package/android/src/testAmazon/java/ml/nami/expo/iap/ExpoNamiIapModuleAmazonFlavorTest.kt +71 -0
  24. package/dist/index.cjs +56 -0
  25. package/dist/index.d.ts +62 -0
  26. package/dist/index.mjs +45 -0
  27. package/expo-module.config.json +9 -0
  28. package/ios/Errors.swift +178 -0
  29. package/ios/ExpoNamiIap.podspec +14 -0
  30. package/ios/ExpoNamiIapModule.swift +81 -0
  31. package/ios/ProductMapper.swift +130 -0
  32. package/ios/PromotionalOfferSigner.swift +78 -0
  33. package/ios/StoreKit2Helper.swift +252 -0
  34. package/ios/Tests/ErrorsTests.swift +189 -0
  35. package/ios/Tests/ProductMapperTests.swift +165 -0
  36. package/ios/Tests/Resources/Products.storekit +210 -0
  37. package/ios/Tests/StoreKit2HelperTests.swift +360 -0
  38. package/ios/TransactionObserver.swift +54 -0
  39. package/package.json +64 -0
  40. package/src/ExpoNamiIapModule.kepler.ts +200 -0
  41. package/src/ExpoNamiIapModule.ts +18 -0
  42. package/src/index.ts +63 -0
  43. package/src/types.ts +46 -0
@@ -0,0 +1,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,8 @@
1
+ package ml.nami.expo.iap
2
+
3
+ import org.junit.Assert.assertTrue
4
+ import org.junit.Test
5
+
6
+ class PlaceholderTest {
7
+ @Test fun `scaffold compiles`() = assertTrue(true)
8
+ }
@@ -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
+ }