@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,78 @@
1
+ //
2
+ // PromotionalOfferSigner.swift
3
+ // ExpoNamiIap
4
+ //
5
+ // Copyright (c) 2024 Nami ML Inc. All rights reserved.
6
+ //
7
+
8
+ import Foundation
9
+ import StoreKit
10
+
11
+ /// Parses a server-provided promotional offer signature blob into a
12
+ /// `Product.PurchaseOption.promotionalOffer(...)` option.
13
+ ///
14
+ /// The signature is expected to be a Base64-encoded JSON object with the
15
+ /// following structure (matching the Nami Apple SDK's `NamiApplePromo`):
16
+ ///
17
+ /// ```json
18
+ /// {
19
+ /// "key_id": "<App Store Connect key identifier>",
20
+ /// "nonce": "<UUID string>",
21
+ /// "signature": "<Base64-encoded ECDSA signature>",
22
+ /// "timestamp": <Unix epoch in milliseconds>
23
+ /// }
24
+ /// ```
25
+ ///
26
+ /// This mirrors the format used by `NamiApplePromo.applyPromotionalOffer`
27
+ /// in `sdk/apple/Nami/Models/NamiApplePromo.swift`. The caller passes
28
+ /// the opaque Base64 string received from the Nami backend and this helper
29
+ /// decodes it into the parameters Apple's StoreKit 2 API requires.
30
+ @available(iOS 15.0, tvOS 15.0, *)
31
+ enum PromotionalOfferSigner {
32
+
33
+ /// Build a `Product.PurchaseOption` for a promotional offer from a
34
+ /// Base64-encoded JSON signature blob.
35
+ ///
36
+ /// - Parameters:
37
+ /// - product: The StoreKit `Product` (unused directly but kept for
38
+ /// API parity with `NamiApplePromo.applyPromotionalOffer`).
39
+ /// - offerId: The promotional offer identifier configured in App Store Connect.
40
+ /// - signature: Base64-encoded JSON blob containing `key_id`, `nonce`,
41
+ /// `signature`, and `timestamp`.
42
+ /// - Returns: A `.promotionalOffer` purchase option, or `nil` if the
43
+ /// signature blob is malformed or missing required fields.
44
+ static func option(
45
+ product: Product,
46
+ offerId: String,
47
+ signature: String
48
+ ) -> Product.PurchaseOption? {
49
+ // Decode the Base64 wrapper
50
+ guard let jsonData = Data(base64Encoded: signature) else {
51
+ return nil
52
+ }
53
+
54
+ // Parse JSON
55
+ guard let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
56
+ return nil
57
+ }
58
+
59
+ // Extract required fields
60
+ guard let keyID = json["key_id"] as? String,
61
+ let nonceString = json["nonce"] as? String,
62
+ let nonce = UUID(uuidString: nonceString),
63
+ let sigBase64 = json["signature"] as? String,
64
+ let sigData = Data(base64Encoded: sigBase64),
65
+ let timestamp = json["timestamp"] as? Int
66
+ else {
67
+ return nil
68
+ }
69
+
70
+ return .promotionalOffer(
71
+ offerID: offerId,
72
+ keyID: keyID,
73
+ nonce: nonce,
74
+ signature: sigData,
75
+ timestamp: timestamp
76
+ )
77
+ }
78
+ }
@@ -0,0 +1,252 @@
1
+ //
2
+ // StoreKit2Helper.swift
3
+ // ExpoNamiIap
4
+ //
5
+ // Copyright (c) 2024 Nami ML Inc. All rights reserved.
6
+ //
7
+
8
+ import StoreKit
9
+
10
+ /// Actor that wraps StoreKit 2 `Product` APIs, providing an order-preserving
11
+ /// product lookup with caching and subscription eligibility checks.
12
+ ///
13
+ /// Includes restore (Task 9), connection lifecycle via
14
+ /// `TransactionObserver` (Task 10), and caching.
15
+ @available(iOS 15.0, tvOS 15.0, *)
16
+ actor StoreKit2Helper {
17
+
18
+ static let shared = StoreKit2Helper()
19
+
20
+ private var productCache: [String: Product] = [:]
21
+ private var unfinishedTransactions: [UInt64: Transaction] = [:]
22
+ private let observer = TransactionObserver()
23
+
24
+ // MARK: - getProducts
25
+
26
+ /// Fetch StoreKit products for the given SKU IDs and return them as
27
+ /// bridgeable dictionaries, preserving the input order.
28
+ ///
29
+ /// Products are cached after fetch so subsequent calls for the same IDs
30
+ /// avoid a round-trip to the App Store.
31
+ ///
32
+ /// - Parameter skuIds: Array of product identifiers.
33
+ /// - Returns: Array of dictionaries matching `ExpoNamiIapProduct` TypeScript shape.
34
+ /// - Throws: `ExpoNamiIapError.itemUnavailable` when no products are found.
35
+ func getProducts(_ skuIds: [String]) async throws -> [[String: Any]] {
36
+ let products = try await Product.products(for: skuIds)
37
+ guard !products.isEmpty else {
38
+ throw ExpoNamiIapError.itemUnavailable(message: "No products found for \(skuIds)")
39
+ }
40
+
41
+ // Cache fetched products
42
+ for p in products {
43
+ productCache[p.id] = p
44
+ }
45
+
46
+ // Preserve input order
47
+ let byId = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
48
+ var results: [[String: Any]] = []
49
+ for skuId in skuIds {
50
+ if let p = byId[skuId] {
51
+ results.append(await ProductMapper.toDict(p))
52
+ }
53
+ }
54
+ return results
55
+ }
56
+
57
+ // MARK: - checkEligibility
58
+
59
+ /// Check intro-offer and promotional-offer eligibility for the given SKU IDs.
60
+ ///
61
+ /// For subscription products, `introEligible` reflects `isEligibleForIntroOffer`
62
+ /// and `promoEligible` is `true` when no active entitlement exists in the same
63
+ /// subscription group. Non-subscription products always return `false` for both.
64
+ ///
65
+ /// - Parameter skuIds: Array of product identifiers to check.
66
+ /// - Returns: Array of dictionaries with `skuId`, `introEligible`, and `promoEligible`.
67
+ /// - Throws: `ExpoNamiIapError.itemUnavailable` if a product cannot be found.
68
+ func checkEligibility(_ skuIds: [String]) async throws -> [[String: Any]] {
69
+ var results: [[String: Any]] = []
70
+ for skuId in skuIds {
71
+ let product = try await fetchProduct(skuId)
72
+
73
+ let introEligible: Bool
74
+ let promoEligible: Bool
75
+
76
+ if let sub = product.subscription {
77
+ introEligible = await sub.isEligibleForIntroOffer
78
+ promoEligible = await promoEligibleFor(subscription: sub)
79
+ } else {
80
+ introEligible = false
81
+ promoEligible = false
82
+ }
83
+
84
+ results.append([
85
+ "skuId": skuId,
86
+ "introEligible": introEligible,
87
+ "promoEligible": promoEligible,
88
+ ])
89
+ }
90
+ return results
91
+ }
92
+
93
+ // MARK: - purchase
94
+
95
+ /// Execute a purchase for the given SKU ID, optionally applying an
96
+ /// `appAccountToken` and/or a promotional offer.
97
+ ///
98
+ /// - Parameters:
99
+ /// - skuId: The product identifier to purchase.
100
+ /// - offerId: Promotional offer identifier (from App Store Connect), or `nil`.
101
+ /// - offerSignature: Base64-encoded JSON blob with signing parameters
102
+ /// (`key_id`, `nonce`, `signature`, `timestamp`), or `nil`.
103
+ /// - appAccountToken: A UUID string to associate with the transaction, or `nil`.
104
+ /// - Returns: A dictionary matching the `ExpoNamiIapTransaction` TypeScript shape.
105
+ /// - Throws: `ExpoNamiIapError.userCancelled` if the user cancels, or
106
+ /// `ExpoNamiIapError.unknown` for verification failures and pending results.
107
+ func purchase(
108
+ skuId: String,
109
+ offerId: String?,
110
+ offerSignature: String?,
111
+ appAccountToken: String?
112
+ ) async throws -> [String: Any] {
113
+ let product = try await fetchProduct(skuId)
114
+ var options: Set<Product.PurchaseOption> = []
115
+
116
+ if let tokenStr = appAccountToken, let uuid = UUID(uuidString: tokenStr) {
117
+ options.insert(.appAccountToken(uuid))
118
+ }
119
+
120
+ if let offerId = offerId, let sig = offerSignature {
121
+ if let promoOpt = PromotionalOfferSigner.option(
122
+ product: product,
123
+ offerId: offerId,
124
+ signature: sig
125
+ ) {
126
+ options.insert(promoOpt)
127
+ }
128
+ }
129
+
130
+ let result = try await product.purchase(options: options)
131
+ switch result {
132
+ case .success(let verification):
133
+ let jws = verification.jwsRepresentation
134
+ switch verification {
135
+ case .verified(let txn):
136
+ unfinishedTransactions[txn.id] = txn
137
+ return StoreKit2Helper.txnToDict(txn, jwsRepresentation: jws)
138
+ case .unverified(_, let err):
139
+ throw ExpoNamiIapError.unknown(message: "Unverified transaction: \(err)")
140
+ }
141
+ case .userCancelled:
142
+ throw ExpoNamiIapError.userCancelled()
143
+ case .pending:
144
+ throw ExpoNamiIapError.unknown(message: "Purchase pending")
145
+ @unknown default:
146
+ throw ExpoNamiIapError.unknown(message: "Unknown purchase result")
147
+ }
148
+ }
149
+
150
+ // MARK: - finishTransaction
151
+
152
+ /// Finish (acknowledge) a transaction so it is no longer returned by
153
+ /// `Transaction.unfinished`.
154
+ ///
155
+ /// - Parameter transactionId: The numeric transaction ID as a string.
156
+ func finishTransaction(transactionId: String) async throws {
157
+ guard let id = UInt64(transactionId),
158
+ let txn = unfinishedTransactions[id] else { return }
159
+ await txn.finish()
160
+ unfinishedTransactions.removeValue(forKey: id)
161
+ }
162
+
163
+ // MARK: - restorePurchases
164
+
165
+ /// Return all currently entitled (active) transactions as bridgeable
166
+ /// dictionaries. This iterates `Transaction.currentEntitlements` and
167
+ /// includes only verified entries.
168
+ ///
169
+ /// - Returns: Array of dictionaries matching `ExpoNamiIapTransaction` TypeScript shape.
170
+ func restorePurchases() async throws -> [[String: Any]] {
171
+ var results: [[String: Any]] = []
172
+ for await result in Transaction.currentEntitlements {
173
+ if case .verified(let txn) = result {
174
+ results.append(StoreKit2Helper.txnToDict(txn, jwsRepresentation: result.jwsRepresentation))
175
+ }
176
+ }
177
+ return results
178
+ }
179
+
180
+ // MARK: - txnToDict
181
+
182
+ /// Convert a verified `Transaction` to a bridgeable dictionary matching the
183
+ /// `ExpoNamiIapTransaction` TypeScript shape.
184
+ ///
185
+ /// Marked `nonisolated` and `static` so it can be called from both the actor
186
+ /// and non-isolated contexts such as `TransactionObserver` (Task 10).
187
+ ///
188
+ /// - Parameters:
189
+ /// - txn: The verified transaction.
190
+ /// - jwsRepresentation: The JWS receipt string from `VerificationResult.jwsRepresentation`.
191
+ /// Pass `nil` if unavailable; the `receipt` key will be omitted.
192
+ nonisolated static func txnToDict(_ txn: Transaction, jwsRepresentation: String? = nil) -> [String: Any] {
193
+ var dict: [String: Any] = [
194
+ "skuId": txn.productID,
195
+ "transactionId": String(txn.id),
196
+ "originalTransactionId": String(txn.originalID),
197
+ "purchaseTime": Int(txn.purchaseDate.timeIntervalSince1970 * 1000),
198
+ ]
199
+ if let jws = jwsRepresentation {
200
+ dict["receipt"] = jws
201
+ }
202
+ return dict
203
+ }
204
+
205
+ // MARK: - Connection lifecycle
206
+
207
+ /// Start observing `Transaction.updates` via the internal `TransactionObserver`.
208
+ ///
209
+ /// Each verified transaction is forwarded to `onUpdate` as a bridgeable
210
+ /// dictionary matching the `ExpoNamiIapTransaction` TypeScript shape.
211
+ ///
212
+ /// - Parameter onUpdate: Closure invoked for each verified transaction.
213
+ func initConnection(onUpdate: @escaping @Sendable ([String: Any]) -> Void) async {
214
+ await observer.start(onUpdate: onUpdate)
215
+ }
216
+
217
+ /// Stop observing `Transaction.updates`.
218
+ func endConnection() async {
219
+ await observer.stop()
220
+ }
221
+
222
+ // MARK: - Private helpers
223
+
224
+ /// Retrieve a product from cache or fetch it from StoreKit.
225
+ private func fetchProduct(_ skuId: String) async throws -> Product {
226
+ if let cached = productCache[skuId] {
227
+ return cached
228
+ }
229
+ let products = try await Product.products(for: [skuId])
230
+ guard let p = products.first else {
231
+ throw ExpoNamiIapError.itemUnavailable(message: "No product for \(skuId)")
232
+ }
233
+ productCache[skuId] = p
234
+ return p
235
+ }
236
+
237
+ /// Determine promotional-offer eligibility by checking whether any active
238
+ /// entitlement exists in the same subscription group.
239
+ ///
240
+ /// If an active (non-revoked) transaction exists in the same group the user
241
+ /// is not eligible for a promotional offer.
242
+ private func promoEligibleFor(subscription sub: Product.SubscriptionInfo) async -> Bool {
243
+ for await result in Transaction.currentEntitlements {
244
+ if case .verified(let txn) = result,
245
+ txn.subscriptionGroupID == sub.subscriptionGroupID,
246
+ txn.revocationDate == nil {
247
+ return false
248
+ }
249
+ }
250
+ return true
251
+ }
252
+ }
@@ -0,0 +1,189 @@
1
+ //
2
+ // ErrorsTests.swift
3
+ // ExpoNamiIap
4
+ //
5
+ // Copyright (c) 2024 Nami ML Inc. All rights reserved.
6
+ //
7
+
8
+ import XCTest
9
+ import StoreKit
10
+ @testable import ExpoNamiIap
11
+
12
+ @available(iOS 15.0, tvOS 15.0, *)
13
+ final class ErrorsTests: XCTestCase {
14
+
15
+ // MARK: - ExpoNamiIapError.toDict()
16
+
17
+ func testUserCancelledToDictReturnsCorrectShape() {
18
+ let error = ExpoNamiIapError.userCancelled()
19
+ let dict = error.toDict()
20
+
21
+ XCTAssertEqual(dict["code"] as? String, "user_cancelled")
22
+ XCTAssertNotNil(dict["message"] as? String)
23
+ XCTAssertNil(dict["nativeCode"], "nativeCode should be absent when nil")
24
+ }
25
+
26
+ func testUserCancelledWithNativeCodeIncludesIt() {
27
+ let error = ExpoNamiIapError.userCancelled(nativeCode: "SKError.userCancelled")
28
+ let dict = error.toDict()
29
+
30
+ XCTAssertEqual(dict["code"] as? String, "user_cancelled")
31
+ XCTAssertEqual(dict["nativeCode"] as? String, "SKError.userCancelled")
32
+ }
33
+
34
+ func testNetworkToDictReturnsCorrectShape() {
35
+ let error = ExpoNamiIapError.network(message: "Connection timed out", nativeCode: "NSURLError -1001")
36
+ let dict = error.toDict()
37
+
38
+ XCTAssertEqual(dict["code"] as? String, "network")
39
+ XCTAssertEqual(dict["message"] as? String, "Connection timed out")
40
+ XCTAssertEqual(dict["nativeCode"] as? String, "NSURLError -1001")
41
+ }
42
+
43
+ func testItemUnavailableToDictReturnsCorrectShape() {
44
+ let error = ExpoNamiIapError.itemUnavailable(message: "No products found for [\"com.nami.missing\"]")
45
+ let dict = error.toDict()
46
+
47
+ XCTAssertEqual(dict["code"] as? String, "item_unavailable")
48
+ XCTAssertEqual(dict["message"] as? String, "No products found for [\"com.nami.missing\"]")
49
+ XCTAssertNil(dict["nativeCode"])
50
+ }
51
+
52
+ func testAlreadyOwnedToDictReturnsCorrectShape() {
53
+ let error = ExpoNamiIapError.alreadyOwned()
54
+ let dict = error.toDict()
55
+
56
+ XCTAssertEqual(dict["code"] as? String, "already_owned")
57
+ XCTAssertNotNil(dict["message"] as? String)
58
+ }
59
+
60
+ func testNotAllowedToDictReturnsCorrectShape() {
61
+ let error = ExpoNamiIapError.notAllowed(message: "Not available in storefront")
62
+ let dict = error.toDict()
63
+
64
+ XCTAssertEqual(dict["code"] as? String, "not_allowed")
65
+ XCTAssertEqual(dict["message"] as? String, "Not available in storefront")
66
+ }
67
+
68
+ func testUnknownToDictReturnsCorrectShape() {
69
+ let error = ExpoNamiIapError.unknown(message: "Something went wrong", nativeCode: "systemError(42)")
70
+ let dict = error.toDict()
71
+
72
+ XCTAssertEqual(dict["code"] as? String, "unknown")
73
+ XCTAssertEqual(dict["message"] as? String, "Something went wrong")
74
+ XCTAssertEqual(dict["nativeCode"] as? String, "systemError(42)")
75
+ }
76
+
77
+ // MARK: - Errors.toExpoNamiIapError (StoreKitError mapping)
78
+
79
+ func testStoreKitErrorUserCancelledMapsCorrectly() {
80
+ let storeKitError = StoreKitError.userCancelled
81
+ let result = Errors.toExpoNamiIapError(storeKitError)
82
+
83
+ XCTAssertEqual(result.code, "user_cancelled")
84
+ }
85
+
86
+ func testStoreKitErrorNetworkErrorMapsCorrectly() {
87
+ let urlError = URLError(.notConnectedToInternet)
88
+ let storeKitError = StoreKitError.networkError(urlError)
89
+ let result = Errors.toExpoNamiIapError(storeKitError)
90
+
91
+ XCTAssertEqual(result.code, "network")
92
+ XCTAssertNotNil(result.nativeCode)
93
+ }
94
+
95
+ func testStoreKitErrorNotAvailableInStorefrontMapsToNotAllowed() {
96
+ let storeKitError = StoreKitError.notAvailableInStorefront
97
+ let result = Errors.toExpoNamiIapError(storeKitError)
98
+
99
+ XCTAssertEqual(result.code, "not_allowed")
100
+ }
101
+
102
+ func testStoreKitErrorNotEntitledMapsToNotAllowed() {
103
+ let storeKitError = StoreKitError.notEntitled
104
+ let result = Errors.toExpoNamiIapError(storeKitError)
105
+
106
+ XCTAssertEqual(result.code, "not_allowed")
107
+ }
108
+
109
+ func testStoreKitErrorSystemErrorMapsToUnknown() {
110
+ let underlying = NSError(domain: "TestDomain", code: 42, userInfo: nil)
111
+ let storeKitError = StoreKitError.systemError(underlying)
112
+ let result = Errors.toExpoNamiIapError(storeKitError)
113
+
114
+ XCTAssertEqual(result.code, "unknown")
115
+ XCTAssertNotNil(result.nativeCode)
116
+ }
117
+
118
+ func testStoreKitErrorUnknownCaseMapsToUnknown() {
119
+ let storeKitError = StoreKitError.unknown
120
+ let result = Errors.toExpoNamiIapError(storeKitError)
121
+
122
+ XCTAssertEqual(result.code, "unknown")
123
+ }
124
+
125
+ func testArbitraryErrorMapsToUnknown() {
126
+ let error = NSError(domain: "SomeOther", code: 99, userInfo: [NSLocalizedDescriptionKey: "Boom"])
127
+ let result = Errors.toExpoNamiIapError(error)
128
+
129
+ XCTAssertEqual(result.code, "unknown")
130
+ XCTAssertNotNil(result.nativeCode)
131
+ }
132
+
133
+ func testExpoNamiIapErrorPassesThroughUnchanged() {
134
+ let original = ExpoNamiIapError.network(message: "Offline", nativeCode: "NSURLError")
135
+ let result = Errors.toExpoNamiIapError(original)
136
+
137
+ XCTAssertEqual(result.code, "network")
138
+ XCTAssertEqual(result.message, "Offline")
139
+ XCTAssertEqual(result.nativeCode, "NSURLError")
140
+ }
141
+
142
+ // MARK: - Errors.fromPurchaseResult
143
+
144
+ func testPurchaseResultUserCancelledReturnsCancelledError() async throws {
145
+ let result = Errors.fromPurchaseResult(.userCancelled)
146
+
147
+ let error = try XCTUnwrap(result, "userCancelled must return non-nil")
148
+ XCTAssertEqual(error.code, "user_cancelled")
149
+ }
150
+
151
+ func testPurchaseResultPendingReturnsPendingAsUnknown() async throws {
152
+ let result = Errors.fromPurchaseResult(.pending)
153
+
154
+ let error = try XCTUnwrap(result, "pending must return non-nil")
155
+ XCTAssertEqual(error.code, "unknown")
156
+ XCTAssertTrue(error.message.lowercased().contains("pending"))
157
+ }
158
+
159
+ // NOTE: Product.PurchaseResult.success cannot be constructed in tests without
160
+ // a real VerificationResult<Transaction>. We verify nil-return via integration tests.
161
+
162
+ // MARK: - Dict round-trip
163
+
164
+ func testToDictRoundTripPreservesAllFields() {
165
+ let error = ExpoNamiIapError.network(message: "Timeout", nativeCode: "NSURLError -1001")
166
+ let dict = error.toDict()
167
+
168
+ XCTAssertEqual(dict["code"] as? String, "network")
169
+ XCTAssertEqual(dict["message"] as? String, "Timeout")
170
+ XCTAssertEqual(dict["nativeCode"] as? String, "NSURLError -1001")
171
+ XCTAssertEqual(dict.count, 3, "Dict should have exactly code, message, nativeCode")
172
+ }
173
+
174
+ func testToDictOmitsNativeCodeWhenNil() {
175
+ let error = ExpoNamiIapError.userCancelled()
176
+ let dict = error.toDict()
177
+
178
+ XCTAssertEqual(dict.count, 2, "Dict should have only code and message when nativeCode is nil")
179
+ XCTAssertNil(dict["nativeCode"])
180
+ }
181
+
182
+ // MARK: - Errors.itemUnavailable helper
183
+
184
+ func testItemUnavailableHelperProducesCorrectCode() {
185
+ let error = Errors.itemUnavailable("No products found for requested IDs")
186
+ XCTAssertEqual(error.code, "item_unavailable")
187
+ XCTAssertEqual(error.message, "No products found for requested IDs")
188
+ }
189
+ }
@@ -0,0 +1,165 @@
1
+ //
2
+ // ProductMapperTests.swift
3
+ // ExpoNamiIap
4
+ //
5
+ // Copyright (c) 2024 Nami ML Inc. All rights reserved.
6
+ //
7
+
8
+ import XCTest
9
+ import StoreKit
10
+ @testable import ExpoNamiIap
11
+
12
+ @available(iOS 15.0, tvOS 15.0, *)
13
+ final class ProductMapperTests: XCTestCase {
14
+
15
+ private var session: SKTestSession!
16
+
17
+ override func setUp() async throws {
18
+ try await super.setUp()
19
+ session = try SKTestSession(configurationFileNamed: "Products")
20
+ session.disableDialogs = true
21
+ session.clearTransactions()
22
+ }
23
+
24
+ override func tearDown() async throws {
25
+ session = nil
26
+ try await super.tearDown()
27
+ }
28
+
29
+ // MARK: - Helper
30
+
31
+ private func product(for id: String) async throws -> Product {
32
+ let products = try await Product.products(for: [id])
33
+ return try XCTUnwrap(products.first, "Product \(id) not found in StoreKit config")
34
+ }
35
+
36
+ // MARK: - Consumable maps to one_time_purchase
37
+
38
+ func testConsumableProductMapsToOneTimePurchase() async throws {
39
+ let p = try await product(for: "com.nami.test.consumable")
40
+ let dict = await ProductMapper.toDict(p)
41
+
42
+ XCTAssertEqual(dict["product_ref_id"] as? String, "com.nami.test.consumable")
43
+ XCTAssertEqual(dict["product_type"] as? String, "one_time_purchase")
44
+ XCTAssertNotNil(dict["name"] as? String)
45
+ XCTAssertNotNil(dict["description"] as? String)
46
+ XCTAssertNil(dict["subscription_period"], "Consumable must not have subscription_period")
47
+ }
48
+
49
+ // MARK: - Non-consumable maps to one_time_purchase
50
+
51
+ func testNonConsumableProductMapsToOneTimePurchase() async throws {
52
+ let p = try await product(for: "com.nami.test.lifetime")
53
+ let dict = await ProductMapper.toDict(p)
54
+
55
+ XCTAssertEqual(dict["product_ref_id"] as? String, "com.nami.test.lifetime")
56
+ XCTAssertEqual(dict["product_type"] as? String, "one_time_purchase")
57
+ XCTAssertNil(dict["subscription_period"])
58
+ }
59
+
60
+ // MARK: - Subscription with weekly period
61
+
62
+ func testWeeklySubscriptionPeriod() async throws {
63
+ let p = try await product(for: "com.nami.test.weekly")
64
+ let dict = await ProductMapper.toDict(p)
65
+
66
+ XCTAssertEqual(dict["product_type"] as? String, "subscription")
67
+
68
+ let period = try XCTUnwrap(dict["subscription_period"] as? [String: Any])
69
+ XCTAssertEqual(period["type"] as? String, "week")
70
+ XCTAssertEqual(period["count"] as? Int, 1)
71
+ }
72
+
73
+ // MARK: - Default offer contains base price and currency
74
+
75
+ func testDefaultOfferContainsPriceAndCurrency() async throws {
76
+ let p = try await product(for: "com.nami.test.weekly")
77
+ let dict = await ProductMapper.toDict(p)
78
+
79
+ let offers = try XCTUnwrap(dict["offers"] as? [[String: Any]])
80
+ XCTAssertGreaterThanOrEqual(offers.count, 1, "Must have at least a default offer")
81
+
82
+ let defaultOffer = try XCTUnwrap(offers.first(where: { ($0["offer_type"] as? String) == "default" }))
83
+ let price = try XCTUnwrap(defaultOffer["price"] as? Double)
84
+ XCTAssertEqual(price, 9.99, accuracy: 0.001)
85
+
86
+ let currency = try XCTUnwrap(defaultOffer["currency"] as? String)
87
+ XCTAssertFalse(currency.isEmpty, "Currency code must not be empty")
88
+ }
89
+
90
+ // MARK: - Free trial intro offer maps to free_trial offer_type
91
+
92
+ func testFreeTrialIntroOfferMapsToFreeTrialType() async throws {
93
+ let p = try await product(for: "com.nami.test.monthly.trial")
94
+ let dict = await ProductMapper.toDict(p)
95
+
96
+ let offers = try XCTUnwrap(dict["offers"] as? [[String: Any]])
97
+ let trialOffer = offers.first(where: { ($0["offer_type"] as? String) == "free_trial" })
98
+ let trial = try XCTUnwrap(trialOffer, "Expected a free_trial offer")
99
+
100
+ let trialPrice = try XCTUnwrap(trial["price"] as? Double)
101
+ XCTAssertEqual(trialPrice, 0.0, accuracy: 0.001, "Free trial price must be 0")
102
+
103
+ let promoPeriod = try XCTUnwrap(trial["promo_period"] as? [String: Any])
104
+ XCTAssertNotNil(promoPeriod["type"] as? String)
105
+ XCTAssertNotNil(promoPeriod["count"] as? Int)
106
+ }
107
+
108
+ // MARK: - Pay-as-you-go intro offer maps to intro with promo_period
109
+
110
+ func testPayAsYouGoIntroOfferMapsCorrectly() async throws {
111
+ let p = try await product(for: "com.nami.test.monthly.intro")
112
+ let dict = await ProductMapper.toDict(p)
113
+
114
+ let offers = try XCTUnwrap(dict["offers"] as? [[String: Any]])
115
+ let introOffer = offers.first(where: { ($0["offer_type"] as? String) == "intro" })
116
+ let intro = try XCTUnwrap(introOffer, "Expected an intro offer")
117
+
118
+ let introPrice = try XCTUnwrap(intro["price"] as? Double)
119
+ XCTAssertGreaterThan(introPrice, 0.0, "Intro price must be > 0")
120
+
121
+ let promoPeriod = try XCTUnwrap(intro["promo_period"] as? [String: Any])
122
+ XCTAssertEqual(promoPeriod["type"] as? String, "week")
123
+ XCTAssertEqual(promoPeriod["count"] as? Int, 1)
124
+
125
+ let currency = try XCTUnwrap(intro["currency"] as? String)
126
+ XCTAssertFalse(currency.isEmpty)
127
+ }
128
+
129
+ // MARK: - Subscription period for monthly product
130
+
131
+ func testMonthlySubscriptionPeriod() async throws {
132
+ let p = try await product(for: "com.nami.test.monthly.trial")
133
+ let dict = await ProductMapper.toDict(p)
134
+
135
+ let period = try XCTUnwrap(dict["subscription_period"] as? [String: Any])
136
+ XCTAssertEqual(period["type"] as? String, "month")
137
+ XCTAssertEqual(period["count"] as? Int, 1)
138
+ }
139
+
140
+ // MARK: - All required keys present for subscription
141
+
142
+ func testAllRequiredKeysPresent() async throws {
143
+ let p = try await product(for: "com.nami.test.weekly")
144
+ let dict = await ProductMapper.toDict(p)
145
+
146
+ // Keys from NamiProductDetails
147
+ XCTAssertNotNil(dict["product_ref_id"])
148
+ XCTAssertNotNil(dict["product_type"])
149
+ XCTAssertNotNil(dict["offers"])
150
+ XCTAssertNotNil(dict["name"])
151
+ XCTAssertNotNil(dict["description"])
152
+ XCTAssertNotNil(dict["subscription_period"])
153
+ }
154
+
155
+ // MARK: - Consumable has exactly one default offer
156
+
157
+ func testConsumableHasExactlyOneDefaultOffer() async throws {
158
+ let p = try await product(for: "com.nami.test.consumable")
159
+ let dict = await ProductMapper.toDict(p)
160
+
161
+ let offers = try XCTUnwrap(dict["offers"] as? [[String: Any]])
162
+ XCTAssertEqual(offers.count, 1, "Consumable must have exactly one offer")
163
+ XCTAssertEqual(offers[0]["offer_type"] as? String, "default")
164
+ }
165
+ }