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