From 235a7dcdc1ac1fa212b0ce51aa5a52c76740c899 Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Mon, 16 Oct 2023 22:45:29 +0200 Subject: [PATCH] `StoreKit 2`: Optionally send JWS tokens instead of receipts to the backend (#3227) - Added a new option `usesStoreKit2JWS` under `DangerousSettings`. If enabled, the SDK will send a JWS token instead of a receipt to the applicable backend endpoints. - The option must be used in conjunction with `usesStoreKit2IfAvailable` configuration option. ### ToDo - [x] Send JWS token when making a purchase in SK2 mode - [x] Send JWS token when calculating promo offer eligibility - [x] Update `syncTransactions` to send the latest transaction JWS. - [x] Add tests --------- Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> Co-authored-by: Distiller Co-authored-by: Distiller Co-authored-by: Distiller Co-authored-by: Distiller Co-authored-by: Distiller --- RevenueCat.xcodeproj/project.pbxproj | 4 + .../Helpers/ReceiptStrings.swift | 4 + Sources/Misc/DangerousSettings.swift | 18 +- Sources/Misc/Deprecations.swift | 1 + Sources/Networking/Backend.swift | 4 +- Sources/Networking/CustomerAPI.swift | 4 +- Sources/Networking/OfferingsAPI.swift | 4 +- .../PostOfferForSigningOperation.swift | 4 +- .../Operations/PostReceiptDataOperation.swift | 59 +++-- Sources/Purchasing/Purchases/Purchases.swift | 5 +- .../Purchases/PurchasesOrchestrator.swift | 219 ++++++++++++++---- .../Purchases/TransactionPoster.swift | 48 ++-- .../StoreKit2TransactionFetcher.swift | 36 ++- .../StoreKit2TransactionListener.swift | 11 +- .../EncodedAppleReceipt.swift | 36 +++ .../SK1StoreTransaction.swift | 5 + .../SK2StoreTransaction.swift | 4 +- .../StoreTransaction.swift | 9 +- .../ObjCAPITester/RCConfigurationAPI.m | 2 +- .../BaseBackendIntegrationTests.swift | 1 + .../PurchasesOrchestratorTests.swift | 184 ++++++++++++++- .../StoreKit2TransactionFetcherTests.swift | 70 ++++-- .../StoreKit2TransactionListenerTests.swift | 12 +- .../StoreTransactionTests.swift | 5 +- .../StoreKitConfigTestCase+Extensions.swift | 29 ++- .../Core/ConfiguredPurchases.swift | 7 + Tests/UnitTests/Mocks/MockBackend.swift | 8 +- Tests/UnitTests/Mocks/MockOfferingsAPI.swift | 10 +- .../MockStoreKit2TransactionFetcher.swift | 26 +++ .../MockStoreKit2TransactionListener.swift | 6 +- .../Mocks/MockStoreTransaction.swift | 4 +- Tests/UnitTests/Mocks/MockSystemInfo.swift | 12 +- .../BackendPostOfferForSigningTests.swift | 19 +- .../Backend/BackendPostReceiptDataTests.swift | 96 +++++--- ...stsJWSTokenWithProductDataCorrectly.1.json | 26 +++ ...stsJWSTokenWithProductDataCorrectly.1.json | 26 +++ ...stsJWSTokenWithProductDataCorrectly.1.json | 26 +++ ...stsJWSTokenWithProductDataCorrectly.1.json | 26 +++ ...stsJWSTokenWithProductDataCorrectly.1.json | 26 +++ ...stsJWSTokenWithProductDataCorrectly.1.json | 26 +++ .../Purchases/BasePurchasesTests.swift | 11 +- .../Purchases/PurchasesRestoreTests.swift | 2 +- .../Purchases/TransactionPosterTests.swift | 58 ++++- .../BackendSubscriberAttributesTests.swift | 14 +- .../PurchasesSubscriberAttributesTests.swift | 3 + 45 files changed, 1003 insertions(+), 207 deletions(-) create mode 100644 Sources/Purchasing/StoreKitAbstractions/EncodedAppleReceipt.swift create mode 100644 Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS12-testPostsJWSTokenWithProductDataCorrectly.1.json create mode 100644 Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS13-testPostsJWSTokenWithProductDataCorrectly.1.json create mode 100644 Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS14-testPostsJWSTokenWithProductDataCorrectly.1.json create mode 100644 Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS15-testPostsJWSTokenWithProductDataCorrectly.1.json create mode 100644 Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS16-testPostsJWSTokenWithProductDataCorrectly.1.json create mode 100644 Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS17-testPostsJWSTokenWithProductDataCorrectly.1.json diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 6f59b7a146..bdaceff5b0 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -203,6 +203,7 @@ 37E3578711F5FDD5DC6458A8 /* AttributionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3521731D8DC16873F55F3 /* AttributionFetcher.swift */; }; 37E35C8515C5E2D01B0AF5C1 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3507939634ED5A9280544 /* Strings.swift */; }; 42F1DF385E3C1F9903A07FBF /* ProductsFetcherSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFB3CBAA73855779FE828CE2 /* ProductsFetcherSK1.swift */; }; + 4DC546272AD44BBE005CDB35 /* EncodedAppleReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC546262AD44BBE005CDB35 /* EncodedAppleReceipt.swift */; }; 4F0201C42A13C85500091612 /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0201C32A13C85500091612 /* Assertions.swift */; }; 4F05876F2A5DE03F00E9A834 /* PaywallDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F05876E2A5DE03F00E9A834 /* PaywallDataTests.swift */; }; 4F062D322A85A11600A8A613 /* PaywallData+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */; }; @@ -985,6 +986,7 @@ 37E35EEE7783629CDE41B70C /* SystemInfoTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemInfoTests.swift; sourceTree = ""; }; 37E35F783903362B65FB7AF3 /* MockProductsRequestFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockProductsRequestFactory.swift; sourceTree = ""; }; 37E35FDA0A44EA03EA12DAA2 /* DateFormatter+ExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DateFormatter+ExtensionsTests.swift"; sourceTree = ""; }; + 4DC546262AD44BBE005CDB35 /* EncodedAppleReceipt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EncodedAppleReceipt.swift; sourceTree = ""; }; 4F0201C32A13C85500091612 /* Assertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assertions.swift; sourceTree = ""; }; 4F05876E2A5DE03F00E9A834 /* PaywallDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallDataTests.swift; sourceTree = ""; }; 4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaywallData+Localization.swift"; sourceTree = ""; }; @@ -1540,6 +1542,7 @@ isa = PBXGroup; children = ( 4F1428A52A4A1330006CD196 /* Test Data */, + 4DC546262AD44BBE005CDB35 /* EncodedAppleReceipt.swift */, 57EFDC6A27BC1F370057EC39 /* ProductType.swift */, 2C0B98CC2797070B00C5874F /* PromotionalOffer.swift */, 57DE807028074C23008D6C6F /* SK1Storefront.swift */, @@ -3584,6 +3587,7 @@ 57C381DA2796153D009E3940 /* SK1StoreProductDiscount.swift in Sources */, 57DE807328074C76008D6C6F /* SK2Storefront.swift in Sources */, 57A17727276A721D0052D3A8 /* Set+Extensions.swift in Sources */, + 4DC546272AD44BBE005CDB35 /* EncodedAppleReceipt.swift in Sources */, 37E350C67712B9E054FEF297 /* AttributionData.swift in Sources */, 37E3578711F5FDD5DC6458A8 /* AttributionFetcher.swift in Sources */, B302206A27271BCB008F1A0D /* Decoder+Extensions.swift in Sources */, diff --git a/Sources/LocalReceiptParsing/Helpers/ReceiptStrings.swift b/Sources/LocalReceiptParsing/Helpers/ReceiptStrings.swift index e2eb182a9d..49b784a90a 100644 --- a/Sources/LocalReceiptParsing/Helpers/ReceiptStrings.swift +++ b/Sources/LocalReceiptParsing/Helpers/ReceiptStrings.swift @@ -29,6 +29,7 @@ enum ReceiptStrings { case refreshing_empty_receipt case unable_to_load_receipt(Error) case posting_receipt(AppleReceipt, initiationSource: String) + case posting_jws(String, initiationSource: String) case receipt_subscription_purchase_equals_expiration( productIdentifier: String, purchase: Date, @@ -91,6 +92,9 @@ extension ReceiptStrings: LogMessage { return "Posting receipt (source: '\(initiationSource)') (note: the contents might not be up-to-date, " + "but it will be refreshed with Apple's servers):\n\(receipt.debugDescription)" + case let .posting_jws(token, initiationSource): + return "Posting JWS token (source: '\(initiationSource)'):\n\(token)" + case let .receipt_subscription_purchase_equals_expiration( productIdentifier, purchase, diff --git a/Sources/Misc/DangerousSettings.swift b/Sources/Misc/DangerousSettings.swift index 9566d83678..6fef8fbee1 100644 --- a/Sources/Misc/DangerousSettings.swift +++ b/Sources/Misc/DangerousSettings.swift @@ -16,6 +16,7 @@ import Foundation internal struct Internal: InternalDangerousSettingsType { let enableReceiptFetchRetry: Bool + let usesStoreKit2JWS: Bool #if DEBUG let forceServerErrors: Bool @@ -24,18 +25,24 @@ import Foundation init( enableReceiptFetchRetry: Bool = false, + usesStoreKit2JWS: Bool = false, forceServerErrors: Bool = false, forceSignatureFailures: Bool = false, testReceiptIdentifier: String? = nil ) { self.enableReceiptFetchRetry = enableReceiptFetchRetry + self.usesStoreKit2JWS = usesStoreKit2JWS self.forceServerErrors = forceServerErrors self.forceSignatureFailures = forceSignatureFailures self.testReceiptIdentifier = testReceiptIdentifier } #else - init(enableReceiptFetchRetry: Bool = false) { + init( + enableReceiptFetchRetry: Bool = false, + usesStoreKit2JWS: Bool = false + ) { self.enableReceiptFetchRetry = enableReceiptFetchRetry + self.usesStoreKit2JWS = usesStoreKit2JWS } #endif @@ -87,7 +94,8 @@ import Foundation /// - Note: this is `internal` only so the only `public` way to enable `customEntitlementComputation` /// is through ``Purchases/configureInCustomEntitlementsComputationMode(apiKey:appUserID:)``. - @objc internal convenience init(autoSyncPurchases: Bool = true, customEntitlementComputation: Bool) { + @objc internal convenience init(autoSyncPurchases: Bool = true, + customEntitlementComputation: Bool) { self.init(autoSyncPurchases: autoSyncPurchases, customEntitlementComputation: customEntitlementComputation, internalSettings: Internal.default) @@ -113,6 +121,12 @@ internal protocol InternalDangerousSettingsType: Sendable { /// Whether `ReceiptFetcher` can retry fetching receipts. var enableReceiptFetchRetry: Bool { get } + /** + * Controls whether StoreKit 2 JWS tokens are sent to RevenueCat instead of StoreKit 1 receipts. + * Must be used in conjunction with the `usesStoreKit2IfAvailable configuration` option. + */ + var usesStoreKit2JWS: Bool { get } + #if DEBUG /// Whether `HTTPClient` will fake server errors var forceServerErrors: Bool { get } diff --git a/Sources/Misc/Deprecations.swift b/Sources/Misc/Deprecations.swift index 654c1bca07..be9f6a016d 100644 --- a/Sources/Misc/Deprecations.swift +++ b/Sources/Misc/Deprecations.swift @@ -378,6 +378,7 @@ extension CustomerInfo { let transactionIdentifier: String let quantity: Int var storefront: Storefront? { return nil } + internal var jwsRepresentation: String? { return nil } var hasKnownPurchaseDate: Bool { true } var hasKnownTransactionIdentifier: Bool { return true } diff --git a/Sources/Networking/Backend.swift b/Sources/Networking/Backend.swift index a30c25483c..cc2ba222de 100644 --- a/Sources/Networking/Backend.swift +++ b/Sources/Networking/Backend.swift @@ -109,12 +109,12 @@ class Backend { completion: completion) } - func post(receiptData: Data, + func post(receipt: EncodedAppleReceipt, productData: ProductRequestData?, transactionData: PurchasedTransactionData, observerMode: Bool, completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { - self.customer.post(receiptData: receiptData, + self.customer.post(receipt: receipt, productData: productData, transactionData: transactionData, observerMode: observerMode, diff --git a/Sources/Networking/CustomerAPI.swift b/Sources/Networking/CustomerAPI.swift index 8effdc45ee..7e42f4bdbe 100644 --- a/Sources/Networking/CustomerAPI.swift +++ b/Sources/Networking/CustomerAPI.swift @@ -87,7 +87,7 @@ final class CustomerAPI { self.backendConfig.operationQueue.addOperation(postAttributionDataOperation) } - func post(receiptData: Data, + func post(receipt: EncodedAppleReceipt, productData: ProductRequestData?, transactionData: PurchasedTransactionData, observerMode: Bool, @@ -109,7 +109,7 @@ final class CustomerAPI { let postData = PostReceiptDataOperation.PostData( transactionData: transactionData.withAttributesToPost(subscriberAttributesToPost), productData: productData, - receiptData: receiptData, + receipt: receipt, observerMode: observerMode, testReceiptIdentifier: self.backendConfig.systemInfo.testReceiptIdentifier ) diff --git a/Sources/Networking/OfferingsAPI.swift b/Sources/Networking/OfferingsAPI.swift index 2dfda96b69..bfdcefefb2 100644 --- a/Sources/Networking/OfferingsAPI.swift +++ b/Sources/Networking/OfferingsAPI.swift @@ -70,7 +70,7 @@ class OfferingsAPI { func post(offerIdForSigning offerIdentifier: String, productIdentifier: String, subscriptionGroup: String, - receiptData: Data, + receipt: EncodedAppleReceipt, appUserID: String, completion: @escaping OfferSigningResponseHandler) { let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, @@ -79,7 +79,7 @@ class OfferingsAPI { let postOfferData = PostOfferForSigningOperation.PostOfferForSigningData(offerIdentifier: offerIdentifier, productIdentifier: productIdentifier, subscriptionGroup: subscriptionGroup, - receiptData: receiptData) + receipt: receipt) let postOfferForSigningOperation = PostOfferForSigningOperation(configuration: config, postOfferForSigningData: postOfferData, responseHandler: completion) diff --git a/Sources/Networking/Operations/PostOfferForSigningOperation.swift b/Sources/Networking/Operations/PostOfferForSigningOperation.swift index 14002ae383..e6efab6ce3 100644 --- a/Sources/Networking/Operations/PostOfferForSigningOperation.swift +++ b/Sources/Networking/Operations/PostOfferForSigningOperation.swift @@ -22,7 +22,7 @@ class PostOfferForSigningOperation: NetworkOperation { let offerIdentifier: String let productIdentifier: String let subscriptionGroup: String - let receiptData: Data + let receipt: EncodedAppleReceipt } @@ -127,7 +127,7 @@ private extension PostOfferForSigningOperation { init(appUserID: String, data: PostOfferForSigningData) { self.appUserID = appUserID - self.fetchToken = data.receiptData.asFetchToken + self.fetchToken = data.receipt.serialized() self.generateOffers = [ .init( offerID: data.offerIdentifier, diff --git a/Sources/Networking/Operations/PostReceiptDataOperation.swift b/Sources/Networking/Operations/PostReceiptDataOperation.swift index 0d62968bdf..4495ac8ac5 100644 --- a/Sources/Networking/Operations/PostReceiptDataOperation.swift +++ b/Sources/Networking/Operations/PostReceiptDataOperation.swift @@ -53,7 +53,7 @@ final class PostReceiptDataOperation: CacheableNetworkOperation { /// - `subscriberAttributesByKey` let cacheKey = """ - \(configuration.appUserID)-\(postData.isRestore)-\(postData.receiptData.hashString) + \(configuration.appUserID)-\(postData.isRestore)-\(postData.receipt.hash) -\(postData.productData?.cacheKey ?? "") -\(postData.presentedOfferingIdentifier ?? "")-\(postData.observerMode) -\(postData.subscriberAttributesByKey?.debugDescription ?? "") @@ -120,7 +120,7 @@ extension PostReceiptDataOperation { struct PostData { let appUserID: String - let receiptData: Data + let receipt: EncodedAppleReceipt let isRestore: Bool let productData: ProductRequestData? let presentedOfferingIdentifier: String? @@ -151,13 +151,13 @@ extension PostReceiptDataOperation.PostData { init( transactionData data: PurchasedTransactionData, productData: ProductRequestData?, - receiptData: Data, + receipt: EncodedAppleReceipt, observerMode: Bool, testReceiptIdentifier: String? ) { self.init( appUserID: data.appUserID, - receiptData: receiptData, + receipt: receipt, isRestore: data.source.isRestore, productData: productData, presentedOfferingIdentifier: data.presentedOfferingID, @@ -191,23 +191,31 @@ private extension PurchasedTransactionData { private extension PostReceiptDataOperation { func printReceiptData() { - do { - let receipt = try PurchasesReceiptParser.default.parse(from: self.postData.receiptData) - self.log(Strings.receipt.posting_receipt( - receipt, + switch self.postData.receipt { + case .jws(let content): + self.log(Strings.receipt.posting_jws( + content, initiationSource: self.postData.initiationSource.rawValue )) - - for purchase in receipt.inAppPurchases where purchase.purchaseDateEqualsExpiration { - Logger.appleError(Strings.receipt.receipt_subscription_purchase_equals_expiration( - productIdentifier: purchase.productId, - purchase: purchase.purchaseDate, - expiration: purchase.expiresDate + case .receipt(let data): + do { + let receipt = try PurchasesReceiptParser.default.parse(from: data) + self.log(Strings.receipt.posting_receipt( + receipt, + initiationSource: self.postData.initiationSource.rawValue )) - } - } catch { - Logger.appleError(Strings.receipt.parse_receipt_locally_error(error: error)) + for purchase in receipt.inAppPurchases where purchase.purchaseDateEqualsExpiration { + Logger.appleError(Strings.receipt.receipt_subscription_purchase_equals_expiration( + productIdentifier: purchase.productId, + purchase: purchase.purchaseDate, + expiration: purchase.expiresDate + )) + } + + } catch { + Logger.appleError(Strings.receipt.parse_receipt_locally_error(error: error)) + } } } @@ -259,7 +267,7 @@ extension PostReceiptDataOperation.PostData: Encodable { try container.encodeIfPresent(self.testReceiptIdentifier, forKey: .testReceiptIdentifier) } - var fetchToken: String { return self.receiptData.asFetchToken } + var fetchToken: String { return self.receipt.serialized() } } @@ -313,3 +321,18 @@ extension ProductRequestData.InitiationSource: Encodable, RawRepresentable { .dictionaryWithKeys { $0.rawValue } } + +// MARK: - EncodedAppleReceipt + +private extension EncodedAppleReceipt { + + var hash: String { + switch self { + case let .jws(content): + return content.asData.hashString + case let .receipt(data): + return data.hashString + } + } + +} diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index ec602089a5..54f5159fcf 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -293,6 +293,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void let deviceCache = DeviceCache(sandboxEnvironmentDetector: systemInfo, userDefaults: userDefaults) let purchasedProductsFetcher = OfflineCustomerInfoCreator.createPurchasedProductsFetcherIfAvailable() + let transactionFetcher = StoreKit2TransactionFetcher() let backend = Backend( apiKey: apiKey, @@ -340,7 +341,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void operationDispatcher: operationDispatcher, deviceCache: deviceCache, backend: backend, - transactionFetcher: StoreKit2TransactionFetcher(), + transactionFetcher: transactionFetcher, transactionPoster: transactionPoster, systemInfo: systemInfo) @@ -420,6 +421,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void operationDispatcher: operationDispatcher, receiptFetcher: receiptFetcher, receiptParser: receiptParser, + transactionFetcher: transactionFetcher, customerInfoManager: customerInfoManager, backend: backend, transactionPoster: transactionPoster, @@ -442,6 +444,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void operationDispatcher: operationDispatcher, receiptFetcher: receiptFetcher, receiptParser: receiptParser, + transactionFetcher: transactionFetcher, customerInfoManager: customerInfoManager, backend: backend, transactionPoster: transactionPoster, diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index 01b229ead7..14e6d5d7af 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -58,6 +58,7 @@ final class PurchasesOrchestrator { private let operationDispatcher: OperationDispatcher private let receiptFetcher: ReceiptFetcher private let receiptParser: PurchasesReceiptParser + private let transactionFetcher: StoreKit2TransactionFetcherType private let customerInfoManager: CustomerInfoManager private let backend: Backend private let transactionPoster: TransactionPosterType @@ -95,6 +96,7 @@ final class PurchasesOrchestrator { operationDispatcher: OperationDispatcher, receiptFetcher: ReceiptFetcher, receiptParser: PurchasesReceiptParser, + transactionFetcher: StoreKit2TransactionFetcherType, customerInfoManager: CustomerInfoManager, backend: Backend, transactionPoster: TransactionPoster, @@ -116,6 +118,7 @@ final class PurchasesOrchestrator { operationDispatcher: operationDispatcher, receiptFetcher: receiptFetcher, receiptParser: receiptParser, + transactionFetcher: transactionFetcher, customerInfoManager: customerInfoManager, backend: backend, transactionPoster: transactionPoster, @@ -163,6 +166,7 @@ final class PurchasesOrchestrator { operationDispatcher: OperationDispatcher, receiptFetcher: ReceiptFetcher, receiptParser: PurchasesReceiptParser, + transactionFetcher: StoreKit2TransactionFetcherType, customerInfoManager: CustomerInfoManager, backend: Backend, transactionPoster: TransactionPoster, @@ -181,6 +185,7 @@ final class PurchasesOrchestrator { self.operationDispatcher = operationDispatcher self.receiptFetcher = receiptFetcher self.receiptParser = receiptParser + self.transactionFetcher = transactionFetcher self.customerInfoManager = customerInfoManager self.backend = backend self.transactionPoster = transactionPoster @@ -256,46 +261,22 @@ final class PurchasesOrchestrator { return } - self.receiptFetcher.receiptData(refreshPolicy: .onlyIfEmpty) { receiptData, receiptURL in - guard let receiptData = receiptData, !receiptData.isEmpty else { - let underlyingError = ErrorUtils.missingReceiptFileError(receiptURL) - - // Promotional offers require existing purchases. - // If no receipt is found, this is most likely in sandbox with no purchases, - // so producing an "ineligible" error is better. - completion(.failure(ErrorUtils.ineligibleError(error: underlyingError))) - - return + if self.systemInfo.dangerousSettings.internalSettings.usesStoreKit2JWS, + #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + self.sk2PromotionalOffer(forProductDiscount: productDiscount, + discountIdentifier: discountIdentifier, + product: product, + subscriptionGroupIdentifier: subscriptionGroupIdentifier) { result in + completion(result) } - - self.operationDispatcher.dispatchOnWorkerThread { - if !self.receiptParser.receiptHasTransactions(receiptData: receiptData) { - // Promotional offers require existing purchases. - // Fail early if receipt has no transactions. - completion(.failure(ErrorUtils.ineligibleError())) - return - } - - self.backend.offerings.post(offerIdForSigning: discountIdentifier, - productIdentifier: product.productIdentifier, - subscriptionGroup: subscriptionGroupIdentifier, - receiptData: receiptData, - appUserID: self.appUserID) { result in - let result: Result = result - .map { data in - let signedData = PromotionalOffer.SignedData(identifier: discountIdentifier, - keyIdentifier: data.keyIdentifier, - nonce: data.nonce, - signature: data.signature, - timestamp: data.timestamp) - - return .init(discount: productDiscount, signedData: signedData) - } - .mapError { $0.asPurchasesError } - + } else { + self.sk1PromotionalOffer(forProductDiscount: productDiscount, + discountIdentifier: discountIdentifier, + product: product, + subscriptionGroupIdentifier: subscriptionGroupIdentifier) { result in completion(result) } - } + } } @@ -514,14 +495,13 @@ final class PurchasesOrchestrator { // `userCancelled` above comes from `StoreKitError.userCancelled`. // This detects if `Product.PurchaseResult.userCancelled` is true. - let (userCancelled, sk2Transaction) = try await self.storeKit2TransactionListener + let (userCancelled, transaction) = try await self.storeKit2TransactionListener .handle(purchaseResult: result) if userCancelled, self.systemInfo.dangerousSettings.customEntitlementComputation { throw ErrorUtils.purchaseCancelledError() } - let transaction = sk2Transaction.map(StoreTransaction.init(sk2Transaction:)) let customerInfo: CustomerInfo if let transaction = transaction { @@ -1012,7 +992,6 @@ private extension PurchasesOrchestrator { } } - // swiftlint:disable:next function_body_length func syncPurchases(receiptRefreshPolicy: ReceiptRefreshPolicy, isRestore: Bool, initiationSource: ProductRequestData.InitiationSource, @@ -1023,6 +1002,24 @@ private extension PurchasesOrchestrator { Logger.warn(Strings.purchase.restorepurchases_called_with_allow_sharing_appstore_account_false) } + if self.systemInfo.dangerousSettings.internalSettings.usesStoreKit2JWS, + #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + self.syncPurchasesSK2(isRestore: isRestore, + initiationSource: initiationSource, + completion: completion) + } else { + self.syncPurchasesSK1(receiptRefreshPolicy: receiptRefreshPolicy, + isRestore: isRestore, + initiationSource: initiationSource, + completion: completion) + } + } + + // swiftlint:disable:next function_body_length + func syncPurchasesSK1(receiptRefreshPolicy: ReceiptRefreshPolicy, + isRestore: Bool, + initiationSource: ProductRequestData.InitiationSource, + completion: (@Sendable (Result) -> Void)?) { let currentAppUserID = self.appUserID let unsyncedAttributes = self.unsyncedAttributes @@ -1069,7 +1066,7 @@ private extension PurchasesOrchestrator { source: .init(isRestore: isRestore, initiationSource: initiationSource) ) - self.backend.post(receiptData: receiptData, + self.backend.post(receipt: .receipt(receiptData), productData: productRequestData, transactionData: transactionData, observerMode: self.observerMode) { result in @@ -1085,6 +1082,50 @@ private extension PurchasesOrchestrator { } } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func syncPurchasesSK2(isRestore: Bool, + initiationSource: ProductRequestData.InitiationSource, + completion: (@Sendable (Result) -> Void)?) { + let currentAppUserID = self.appUserID + let unsyncedAttributes = self.unsyncedAttributes + + self.attribution.unsyncedAdServicesToken { adServicesToken in + _ = Task { + let transaction = await self.transactionFetcher.firstVerifiedAutoRenewableTransaction + guard let transaction = transaction, let jwsRepresentation = transaction.jwsRepresentation else { + self.customerInfoManager.customerInfo(appUserID: currentAppUserID, + fetchPolicy: .cachedOrFetched) { result in + self.operationDispatcher.dispatchOnMainThread { + completion?(result.mapError(\.asPurchasesError)) + } + } + return + } + + self.createProductRequestData(with: transaction.productIdentifier) { productRequestData in + let transactionData: PurchasedTransactionData = .init( + appUserID: currentAppUserID, + presentedOfferingID: nil, + unsyncedAttributes: unsyncedAttributes, + storefront: transaction.storefront, + source: .init(isRestore: isRestore, initiationSource: initiationSource) + ) + + self.backend.post(receipt: .jws(jwsRepresentation), + productData: productRequestData, + transactionData: transactionData, + observerMode: self.observerMode) { result in + self.handleReceiptPost(result: result, + transactionData: transactionData, + subscriberAttributes: unsyncedAttributes, + adServicesToken: adServicesToken, + completion: completion) + } + } + } + } + } + func handleReceiptPost(result: Result, transactionData: PurchasedTransactionData, subscriberAttributes: SubscriberAttribute.Dictionary, @@ -1225,6 +1266,13 @@ private extension PurchasesOrchestrator { return } + self.createProductRequestData(with: productIdentifier, completion: completion) + } + + func createProductRequestData( + with productIdentifier: String, + completion: @escaping (ProductRequestData?) -> Void + ) { self.productsManager.products(withIdentifiers: [productIdentifier]) { products in let result = products.value?.first.map { ProductRequestData(with: $0, storefront: self.paymentQueueWrapper.currentStorefront) @@ -1245,6 +1293,95 @@ private extension PurchasesOrchestrator { } } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func sk2PromotionalOffer(forProductDiscount productDiscount: StoreProductDiscountType, + discountIdentifier: String, + product: StoreProductType, + subscriptionGroupIdentifier: String, + completion: @escaping @Sendable (Result) -> Void) { + + _ = Task { + let transaction = await self.transactionFetcher.firstVerifiedAutoRenewableTransaction + guard let transaction = transaction, let jwsRepresentation = transaction.jwsRepresentation else { + // Promotional offers require an existing or expired subscription to redeem a promotional offer. + // Fail early if there are no transactions. + completion(.failure(ErrorUtils.ineligibleError())) + return + } + + self.handlePromotionalOffer(forProductDiscount: productDiscount, + discountIdentifier: discountIdentifier, + product: product, + subscriptionGroupIdentifier: subscriptionGroupIdentifier, + receipt: .jws(jwsRepresentation)) { result in + completion(result) + } + } + } + + func sk1PromotionalOffer(forProductDiscount productDiscount: StoreProductDiscountType, + discountIdentifier: String, + product: StoreProductType, + subscriptionGroupIdentifier: String, + completion: @escaping @Sendable (Result) -> Void) { + self.receiptFetcher.receiptData(refreshPolicy: .onlyIfEmpty) { receiptData, receiptURL in + guard let receiptData = receiptData, !receiptData.isEmpty else { + let underlyingError = ErrorUtils.missingReceiptFileError(receiptURL) + + // Promotional offers require existing purchases. + // If no receipt is found, this is most likely in sandbox with no purchases, + // so producing an "ineligible" error is better. + completion(.failure(ErrorUtils.ineligibleError(error: underlyingError))) + + return + } + + self.operationDispatcher.dispatchOnWorkerThread { + if !self.receiptParser.receiptHasTransactions(receiptData: receiptData) { + // Promotional offers require existing purchases. + // Fail early if receipt has no transactions. + completion(.failure(ErrorUtils.ineligibleError())) + return + } + self.handlePromotionalOffer(forProductDiscount: productDiscount, + discountIdentifier: discountIdentifier, + product: product, + subscriptionGroupIdentifier: subscriptionGroupIdentifier, + receipt: .receipt(receiptData)) { result in + completion(result) + } + } + } + } + + // swiftlint:disable:next function_parameter_count + func handlePromotionalOffer(forProductDiscount productDiscount: StoreProductDiscountType, + discountIdentifier: String, + product: StoreProductType, + subscriptionGroupIdentifier: String, + receipt: EncodedAppleReceipt, + completion: @escaping @Sendable (Result) -> Void) { + self.backend.offerings.post(offerIdForSigning: discountIdentifier, + productIdentifier: product.productIdentifier, + subscriptionGroup: subscriptionGroupIdentifier, + receipt: receipt, + appUserID: self.appUserID) { result in + let result: Result = result + .map { data in + let signedData = PromotionalOffer.SignedData(identifier: discountIdentifier, + keyIdentifier: data.keyIdentifier, + nonce: data.nonce, + signature: data.signature, + timestamp: data.timestamp) + + return .init(discount: productDiscount, signedData: signedData) + } + .mapError { $0.asPurchasesError } + + completion(result) + } + } + } private extension PurchasesOrchestrator { diff --git a/Sources/Purchasing/Purchases/TransactionPoster.swift b/Sources/Purchasing/Purchases/TransactionPoster.swift index 92b5950d70..aa04fe235b 100644 --- a/Sources/Purchasing/Purchases/TransactionPoster.swift +++ b/Sources/Purchasing/Purchases/TransactionPoster.swift @@ -90,21 +90,31 @@ final class TransactionPoster: TransactionPosterType { paywallSessionID: data.presentedPaywall?.sessionIdentifier )) - self.receiptFetcher.receiptData( - refreshPolicy: self.refreshRequestPolicy(forProductIdentifier: transaction.productIdentifier) - ) { receiptData, receiptURL in - if let receiptData = receiptData, !receiptData.isEmpty { - self.fetchProductsAndPostReceipt( - transaction: transaction, - data: data, - receiptData: receiptData, - completion: completion - ) - } else { - self.handleReceiptPost(withTransaction: transaction, - result: .failure(.missingReceiptFile(receiptURL)), - subscriberAttributes: nil, - completion: completion) + if systemInfo.dangerousSettings.internalSettings.usesStoreKit2JWS, + let jwsRepresentation = transaction.jwsRepresentation { + self.fetchProductsAndPostReceipt( + transaction: transaction, + data: data, + receipt: .jws(jwsRepresentation), + completion: completion + ) + } else { + self.receiptFetcher.receiptData( + refreshPolicy: self.refreshRequestPolicy(forProductIdentifier: transaction.productIdentifier) + ) { receiptData, receiptURL in + if let receiptData = receiptData, !receiptData.isEmpty { + self.fetchProductsAndPostReceipt( + transaction: transaction, + data: data, + receipt: .receipt(receiptData), + completion: completion + ) + } else { + self.handleReceiptPost(withTransaction: transaction, + result: .failure(.missingReceiptFile(receiptURL)), + subscriberAttributes: nil, + completion: completion) + } } } } @@ -189,14 +199,14 @@ private extension TransactionPoster { func fetchProductsAndPostReceipt( transaction: StoreTransactionType, data: PurchasedTransactionData, - receiptData: Data, + receipt: EncodedAppleReceipt, completion: @escaping CustomerAPI.CustomerInfoResponseHandler ) { if let productIdentifier = transaction.productIdentifier.notEmpty { self.product(with: productIdentifier) { product in self.postReceipt(transaction: transaction, purchasedTransactionData: data, - receiptData: receiptData, + receipt: receipt, product: product, completion: completion) } @@ -244,12 +254,12 @@ private extension TransactionPoster { func postReceipt(transaction: StoreTransactionType, purchasedTransactionData: PurchasedTransactionData, - receiptData: Data, + receipt: EncodedAppleReceipt, product: StoreProduct?, completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { let productData = product.map { ProductRequestData(with: $0, storefront: purchasedTransactionData.storefront) } - self.backend.post(receiptData: receiptData, + self.backend.post(receipt: receipt, productData: productData, transactionData: purchasedTransactionData, observerMode: self.observerMode) { result in diff --git a/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift b/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift index 3cd3bf2d16..87295c94df 100644 --- a/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift +++ b/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift @@ -22,6 +22,12 @@ protocol StoreKit2TransactionFetcherType: Sendable { @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) var hasPendingConsumablePurchase: Bool { get async } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var firstVerifiedAutoRenewableTransaction: StoreTransaction? { get async } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var firstVerifiedTransaction: StoreTransaction? { get async } + } final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { @@ -31,8 +37,7 @@ final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { get async { return await StoreKit.Transaction .unfinished - .compactMap { $0.verifiedTransaction } - .map { StoreTransaction(sk2Transaction: $0) } + .compactMap { $0.verifiedStoreTransaction } .extractValues() } } @@ -49,6 +54,25 @@ final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { } } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var firstVerifiedAutoRenewableTransaction: StoreTransaction? { + get async { + await StoreKit.Transaction.all + .compactMap { $0.verifiedStoreTransaction } + .filter { $0.sk2Transaction?.productType == .autoRenewable } + .first { _ in true } + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var firstVerifiedTransaction: StoreTransaction? { + get async { + await StoreKit.Transaction.all + .compactMap { $0.verifiedStoreTransaction } + .first { _ in true } + } + } + } // MARK: - @@ -70,4 +94,12 @@ extension StoreKit.VerificationResult where SignedType == StoreKit.Transaction { } } + fileprivate var verifiedStoreTransaction: StoreTransaction? { + switch self { + case let .verified(transaction): return StoreTransaction(sk2Transaction: transaction, + jwsRepresentation: self.jwsRepresentation) + case .unverified: return nil + } + } + } diff --git a/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift b/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift index 7afc46473d..ee5ac08531 100644 --- a/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift +++ b/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift @@ -52,7 +52,7 @@ protocol StoreKit2TransactionListenerType: Sendable { actor StoreKit2TransactionListener: StoreKit2TransactionListenerType { /// Similar to ``PurchaseResultData`` but with an optional `CustomerInfo` - typealias ResultData = (userCancelled: Bool, transaction: SK2Transaction?) + typealias ResultData = (userCancelled: Bool, transaction: StoreTransaction?) typealias TransactionResult = StoreKit.VerificationResult private(set) var taskHandle: Task? @@ -122,7 +122,6 @@ actor StoreKit2TransactionListener: StoreKit2TransactionListenerType { case let .success(verificationResult): let transaction = try await self.handle(transactionResult: verificationResult, fromTransactionUpdate: false) - return (false, transaction) case .pending: throw ErrorUtils.paymentDeferredError() @@ -146,7 +145,7 @@ private extension StoreKit2TransactionListener { func handle( transactionResult: TransactionResult, fromTransactionUpdate: Bool - ) async throws -> SK2Transaction { + ) async throws -> StoreTransaction { switch transactionResult { case let .unverified(unverifiedTransaction, verificationError): throw ErrorUtils.storeProblemError( @@ -158,6 +157,8 @@ private extension StoreKit2TransactionListener { ) case let .verified(verifiedTransaction): + let transaction = StoreTransaction(sk2Transaction: verifiedTransaction, + jwsRepresentation: transactionResult.jwsRepresentation) if fromTransactionUpdate, let delegate = self.delegate { Logger.debug(Strings.purchase.sk2_transactions_update_received_transaction( productID: verifiedTransaction.productID @@ -165,11 +166,11 @@ private extension StoreKit2TransactionListener { try await delegate.storeKit2TransactionListener( self, - updatedTransaction: StoreTransaction(sk2Transaction: verifiedTransaction) + updatedTransaction: transaction ) } - return verifiedTransaction + return transaction } } diff --git a/Sources/Purchasing/StoreKitAbstractions/EncodedAppleReceipt.swift b/Sources/Purchasing/StoreKitAbstractions/EncodedAppleReceipt.swift new file mode 100644 index 0000000000..7fb387d5dc --- /dev/null +++ b/Sources/Purchasing/StoreKitAbstractions/EncodedAppleReceipt.swift @@ -0,0 +1,36 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// EncodedAppleReceipt.swift +// +// Created by Mark Villacampa on 6/10/23. + +import Foundation + +/// Represents an `AppleReceipt` that's been encoded +/// in a suitable representation for the RevenueCat backend. +enum EncodedAppleReceipt: Equatable { + + case jws(String) + case receipt(Data) + +} + +extension EncodedAppleReceipt { + + func serialized() -> String { + switch self { + case .jws(let jws): + return jws + case .receipt(let data): + return data.asFetchToken + } + } + +} diff --git a/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift b/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift index 4a20357222..bef5f8d014 100644 --- a/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift +++ b/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift @@ -36,6 +36,11 @@ internal struct SK1StoreTransaction: StoreTransactionType { return nil } + internal var jwsRepresentation: String? { + // This is only available on StoreKit 2 transactions. + return nil + } + var hasKnownPurchaseDate: Bool { return self.underlyingSK1Transaction.transactionDate != nil } diff --git a/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift b/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift index b488fdac1d..6a1f83b2aa 100644 --- a/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift +++ b/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift @@ -16,13 +16,14 @@ import StoreKit @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) internal struct SK2StoreTransaction: StoreTransactionType { - init(sk2Transaction: SK2Transaction) { + init(sk2Transaction: SK2Transaction, jwsRepresentation: String) { self.underlyingSK2Transaction = sk2Transaction self.productIdentifier = sk2Transaction.productID self.purchaseDate = sk2Transaction.purchaseDate self.transactionIdentifier = String(sk2Transaction.id) self.quantity = sk2Transaction.purchasedQuantity + self.jwsRepresentation = jwsRepresentation #if swift(>=5.9) if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { @@ -42,6 +43,7 @@ internal struct SK2StoreTransaction: StoreTransactionType { let transactionIdentifier: String let quantity: Int let storefront: Storefront? + let jwsRepresentation: String? var hasKnownPurchaseDate: Bool { return true } var hasKnownTransactionIdentifier: Bool { return true } diff --git a/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift b/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift index 8b0a2884cf..f70a29d581 100644 --- a/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift +++ b/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift @@ -42,6 +42,7 @@ public typealias SK2Transaction = StoreKit.Transaction @objc public var transactionIdentifier: String { self.transaction.transactionIdentifier } @objc public var quantity: Int { self.transaction.quantity } @objc public var storefront: Storefront? { self.transaction.storefront } + @objc internal var jwsRepresentation: String? { self.transaction.jwsRepresentation } var hasKnownPurchaseDate: Bool { return self.transaction.hasKnownPurchaseDate } var hasKnownTransactionIdentifier: Bool { self.transaction.hasKnownTransactionIdentifier } @@ -112,6 +113,10 @@ internal protocol StoreTransactionType: Sendable { /// - Note: this is only available for StoreKit 2 transactions starting with iOS 17. var storefront: Storefront? { get } + /// The raw JWS repesentation of the transaction. + /// - Note: this is only available for StoreKit 2 transactions. + var jwsRepresentation: String? { get } + /// Indicates to the App Store that the app delivered the purchased content /// or enabled the service to finish the transaction. func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) @@ -127,8 +132,8 @@ extension StoreTransaction { } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - internal convenience init(sk2Transaction: SK2Transaction) { - self.init(SK2StoreTransaction(sk2Transaction: sk2Transaction)) + internal convenience init(sk2Transaction: SK2Transaction, jwsRepresentation: String) { + self.init(SK2StoreTransaction(sk2Transaction: sk2Transaction, jwsRepresentation: jwsRepresentation)) } /// Returns the `SKPaymentTransaction` if this `StoreTransaction` represents a `SKPaymentTransaction`. diff --git a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCConfigurationAPI.m b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCConfigurationAPI.m index d2710181c3..a7f751a127 100644 --- a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCConfigurationAPI.m +++ b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCConfigurationAPI.m @@ -18,7 +18,7 @@ + (void)checkAPI { withUserDefaults:NSUserDefaults.standardUserDefaults] withAppUserID:@""] withAppUserID:nil] - withDangerousSettings:[[RCDangerousSettings alloc] init]] + withDangerousSettings:[[RCDangerousSettings alloc] initWithAutoSyncPurchases:true]] withNetworkTimeout:1] withStoreKit1Timeout: 1] withPlatformInfo:[[RCPlatformInfo alloc] initWithFlavor:@"" version:@""]] diff --git a/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift b/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift index 3da0054edc..5db61ec0e5 100644 --- a/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift @@ -230,6 +230,7 @@ private extension BaseBackendIntegrationTests { extension BaseBackendIntegrationTests: InternalDangerousSettingsType { + var usesStoreKit2JWS: Bool { false } var forceServerErrors: Bool { return self.serverIsDown } var forceSignatureFailures: Bool { return false } var testReceiptIdentifier: String? { return self.testUUID.uuidString } diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift index 0e1cb5ac4b..3c25ecf16e 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift @@ -21,6 +21,7 @@ import XCTest class PurchasesOrchestratorTests: StoreKitConfigTestCase { private var productsManager: MockProductsManager! + private var purchasedProductsFetcher: MockPurchasedProductsFetcher! private var storeKit1Wrapper: MockStoreKit1Wrapper! private var systemInfo: MockSystemInfo! private var subscriberAttributesManager: MockSubscriberAttributesManager! @@ -40,6 +41,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { private var mockBeginRefundRequestHelper: MockBeginRefundRequestHelper! private var mockOfferingsManager: MockOfferingsManager! private var mockStoreMessagesHelper: MockStoreMessagesHelper! + private var mockTransactionFetcher: MockStoreKit2TransactionFetcher! private var orchestrator: PurchasesOrchestrator! @@ -52,6 +54,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { self.productsManager = MockProductsManager(systemInfo: self.systemInfo, requestTimeout: Configuration.storeKitRequestTimeoutDefault) + self.purchasedProductsFetcher = .init() self.operationDispatcher = MockOperationDispatcher() self.receiptFetcher = MockReceiptFetcher(requestFetcher: MockRequestFetcher(), systemInfo: self.systemInfo) self.receiptParser = MockReceiptParser() @@ -92,8 +95,8 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { self.mockBeginRefundRequestHelper = MockBeginRefundRequestHelper(systemInfo: self.systemInfo, customerInfoManager: self.customerInfoManager, currentUserProvider: self.currentUserProvider) - self.mockStoreMessagesHelper = .init() + self.mockTransactionFetcher = MockStoreKit2TransactionFetcher() self.setUpStoreKit1Wrapper() self.setUpAttribution() self.setUpOrchestrator() @@ -113,13 +116,15 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { fileprivate func setUpSystemInfo( finishTransactions: Bool = true, - storeKit2Setting: StoreKit2Setting = .default + storeKit2Setting: StoreKit2Setting = .default, + usesStoreKit2JWS: Bool = false ) { let platformInfo = Purchases.PlatformInfo(flavor: "xyz", version: "1.2.3") - self.systemInfo = MockSystemInfo(platformInfo: platformInfo, - finishTransactions: finishTransactions, - storeKit2Setting: storeKit2Setting) + self.systemInfo = .init(platformInfo: platformInfo, + finishTransactions: finishTransactions, + storeKit2Setting: storeKit2Setting, + usesStoreKit2JWS: usesStoreKit2JWS) self.systemInfo.stubbedIsSandbox = true } @@ -152,6 +157,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { operationDispatcher: self.operationDispatcher, receiptFetcher: self.receiptFetcher, receiptParser: self.receiptParser, + transactionFetcher: self.mockTransactionFetcher, customerInfoManager: self.customerInfoManager, backend: self.backend, transactionPoster: self.transactionPoster, @@ -177,6 +183,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { operationDispatcher: self.operationDispatcher, receiptFetcher: self.receiptFetcher, receiptParser: self.receiptParser, + transactionFetcher: self.mockTransactionFetcher, customerInfoManager: self.customerInfoManager, backend: self.backend, transactionPoster: self.transactionPoster, @@ -456,9 +463,11 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { expect(self.offerings.invokedPostOfferCount) == 1 expect(self.offerings.invokedPostOfferParameters?.offerIdentifier) == storeProductDiscount.offerIdentifier + expect(self.offerings.invokedPostOfferParameters?.data?.serialized()) == + self.receiptFetcher.mockReceiptData.asFetchToken } - func testGetPromotionalOfferFailsWithIneligibleIfNoReceiptIsFound() async throws { + func testGetSK1PromotionalOfferFailsWithIneligibleIfNoReceiptIsFound() async throws { self.receiptFetcher.shouldReturnReceipt = false let product = try await self.fetchSk1Product() @@ -484,7 +493,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { expect(self.offerings.invokedPostOffer) == false } - func testGetPromotionalOfferFailsWithIneligibleIfReceiptHasNoTransactions() async throws { + func testGetSK1PromotionalOfferFailsWithIneligibleIfReceiptHasNoTransactions() async throws { self.receiptParser.stubbedReceiptHasTransactionsResult = false let product = try await self.fetchSk1Product() @@ -510,7 +519,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { expect(self.offerings.invokedPostOffer) == false } - func testGetPromotionalOfferWorksWhenReceiptHasTransactions() async throws { + func testGetSK1PromotionalOfferWorksWhenReceiptHasTransactions() async throws { customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo offerings.stubbedPostOfferCompletionResult = .success(("signature", "identifier", UUID(), 12345)) self.receiptParser.stubbedReceiptHasTransactionsResult = true @@ -644,6 +653,75 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { expect(self.backend.invokedPostReceiptDataCount) == 0 } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testGetSK2PromotionalOfferWorksIfThereIsATransaction() async throws { + self.setUpSystemInfo(storeKit2Setting: .enabledForCompatibleDevices, usesStoreKit2JWS: true) + self.setUpOrchestrator() + self.setUpStoreKit2Listener() + + let transaction = try await createTransaction(finished: true) + self.mockTransactionFetcher.stubbedFirstVerifiedAutoRenewableTransaction = transaction + + customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo + offerings.stubbedPostOfferCompletionResult = .success(("signature", "identifier", UUID(), 12345)) + self.receiptParser.stubbedReceiptHasTransactionsResult = true + + let product = try await fetchSk2Product() + + let storeProductDiscount = MockStoreProductDiscount(offerIdentifier: "offerid1", + currencyCode: product.priceFormatStyle.currencyCode, + price: 11.1, + localizedPriceString: "$11.10", + paymentMode: .payAsYouGo, + subscriptionPeriod: .init(value: 1, unit: .month), + numberOfPeriods: 2, + type: .promotional) + + let result = try await Async.call { completion in + orchestrator.promotionalOffer(forProductDiscount: storeProductDiscount, + product: StoreProduct(sk2Product: product), + completion: completion) + } + + expect(result.signedData.identifier) == storeProductDiscount.offerIdentifier + + expect(self.offerings.invokedPostOfferCount) == 1 + expect(self.offerings.invokedPostOfferParameters?.offerIdentifier) == storeProductDiscount.offerIdentifier + expect(self.offerings.invokedPostOfferParameters?.data?.serialized()) == transaction.jwsRepresentation + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testGetSK2PromotionalOfferFailsWithIneligibleIfNoTransactionIsFound() async throws { + self.setUpSystemInfo(storeKit2Setting: .enabledForCompatibleDevices, usesStoreKit2JWS: true) + self.setUpOrchestrator() + self.setUpStoreKit2Listener() + + self.mockTransactionFetcher.stubbedFirstVerifiedAutoRenewableTransaction = nil + + let product = try await self.fetchSk2Product() + let storeProductDiscount = MockStoreProductDiscount(offerIdentifier: "offerid1", + currencyCode: product.priceFormatStyle.currencyCode, + price: 11.1, + localizedPriceString: "$11.10", + paymentMode: .payAsYouGo, + subscriptionPeriod: .init(value: 1, unit: .month), + numberOfPeriods: 2, + type: .promotional) + + do { + _ = try await Async.call { completion in + self.orchestrator.promotionalOffer(forProductDiscount: storeProductDiscount, + product: StoreProduct(sk2Product: product), + completion: completion) + } + fail("Expected error") + } catch { + expect(error).to(matchError(ErrorCode.ineligibleError)) + } + + expect(self.offerings.invokedPostOffer) == false + } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func testPurchaseSK2PackageReturnsCorrectValues() async throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() @@ -652,7 +730,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - mockStoreKit2TransactionListener?.mockTransaction = .init(mockTransaction) + mockStoreKit2TransactionListener?.mockTransaction = .init(mockTransaction.underlyingTransaction) let product = try await self.fetchSk2Product() @@ -665,7 +743,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { package: package, promotionalOffer: nil) - expect(transaction?.sk2Transaction) == mockTransaction + expect(transaction?.sk2Transaction) == mockTransaction.underlyingTransaction expect(userCancelled) == false let expectedCustomerInfo: CustomerInfo = .emptyInfo @@ -1422,6 +1500,92 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { ) } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testSyncPurchasesPostsTheReceipt() async throws { + self.setUpSystemInfo(storeKit2Setting: .enabledForCompatibleDevices, usesStoreKit2JWS: true) + self.setUpOrchestrator() + self.setUpStoreKit2Listener() + + let transaction = try await createTransaction(finished: true) + self.mockTransactionFetcher.stubbedFirstVerifiedAutoRenewableTransaction = transaction + let product = try await self.fetchSk2StoreProduct() + self.productsManager.stubbedSk2StoreProductsResult = .success([product]) + self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) + + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + isRestore: false, + initiationSource: .purchase) + + expect(self.backend.invokedPostReceiptData).to(beTrue()) + expect(customerInfo) == mockCustomerInfo + } + + func testSyncPurchasesDoesntPostAndReturnsCustomerInfoIfNoTransaction() async throws { + self.setUpSystemInfo(storeKit2Setting: .enabledForCompatibleDevices, usesStoreKit2JWS: true) + self.setUpOrchestrator() + self.setUpStoreKit2Listener() + + self.mockTransactionFetcher.stubbedFirstVerifiedAutoRenewableTransaction = nil + self.customerInfoManager.stubbedCustomerInfoResult = .success(mockCustomerInfo) + + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + isRestore: false, + initiationSource: .purchase) + expect(self.backend.invokedPostReceiptData).to(beFalse()) + expect(self.customerInfoManager.invokedCustomerInfo).to(beTrue()) + expect(customerInfo) == mockCustomerInfo + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testSyncPurchasesCallsSuccessDelegateMethod() async throws { + self.setUpSystemInfo(storeKit2Setting: .enabledForCompatibleDevices, usesStoreKit2JWS: true) + self.setUpOrchestrator() + self.setUpStoreKit2Listener() + + let transaction = try await createTransaction(finished: true) + self.mockTransactionFetcher.stubbedFirstVerifiedAutoRenewableTransaction = transaction + + let customerInfo = try CustomerInfo(data: [ + "request_date": "2019-08-16T10:30:42Z", + "subscriber": [ + "first_seen": "2019-07-17T00:05:54Z", + "original_app_user_id": "foo", + "subscriptions": [:] as [String: Any], + "other_purchases": [:] as [String: Any], + "original_application_version": NSNull() + ] as [String: Any] + ]) + self.backend.stubbedPostReceiptResult = .success(customerInfo) + + let receivedCustomerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + isRestore: false, + initiationSource: .purchase) + + expect(receivedCustomerInfo) === customerInfo + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testSyncPurchasesPassesErrorOnFailure() async throws { + self.setUpSystemInfo(storeKit2Setting: .enabledForCompatibleDevices, usesStoreKit2JWS: true) + self.setUpOrchestrator() + self.setUpStoreKit2Listener() + + let transaction = try await self.createTransaction(finished: true) + self.mockTransactionFetcher.stubbedFirstVerifiedAutoRenewableTransaction = transaction + + let expectedError: BackendError = .missingAppUserID() + + self.backend.stubbedPostReceiptResult = .failure(expectedError) + + do { + _ = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + isRestore: false, + initiationSource: .purchase) + fail("Expected error") + } catch { + expect(error).to(matchError(expectedError.asPurchasesError)) + } + } } @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) diff --git a/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionFetcherTests.swift b/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionFetcherTests.swift index 3e5a12d384..110c37fb1e 100644 --- a/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionFetcherTests.swift +++ b/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionFetcherTests.swift @@ -45,7 +45,8 @@ class StoreKit2TransactionFetcherTests: StoreKitConfigTestCase { } func testOneUnfinishedConsumablePurchase() async throws { - let transaction = try await self.createTransactionForConsumableProduct(finished: false) + let transaction = try await self.createTransaction(productID: Self.consumable, + finished: false) let result = await self.fetcher.unfinishedVerifiedTransactions expect(result) == [transaction] @@ -83,38 +84,75 @@ class StoreKit2TransactionFetcherTests: StoreKitConfigTestCase { } func testHasNoPendingConsumablePurchaseWithFinishedConsumable() async throws { - _ = try await self.createTransactionForConsumableProduct(finished: true) + _ = try await self.createTransaction(productID: Self.consumable, finished: true) let result = await self.fetcher.hasPendingConsumablePurchase expect(result) == false } func testHasPendingConsumablePurchase() async throws { - _ = try await self.createTransactionForConsumableProduct(finished: false) + _ = try await self.createTransaction(productID: Self.consumable, finished: false) let result = await self.fetcher.hasPendingConsumablePurchase expect(result) == true } -} + // MARK: - firstVerifiedAutoRenewableTransaction -@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) -private extension StoreKit2TransactionFetcherTests { + func testHasFirstVerifiedAutoRenewableTransaction() async throws { + let transaction = try await self.createTransaction(finished: true) + let result = await self.fetcher.firstVerifiedAutoRenewableTransaction + expect(result) == transaction + } + + func testDoesNotHaveFirstVerifiedAutoRenewableTransaction() async throws { + let result = await self.fetcher.firstVerifiedAutoRenewableTransaction + expect(result) == nil + } + + func testFirstVerifiedAutoRenewableTransactionDoesNotIncludeFinishedConsumableTransaction() async throws { + _ = try await self.createTransaction(productID: Self.consumable, finished: true) + let result = await self.fetcher.firstVerifiedAutoRenewableTransaction + expect(result) == nil + } + + func testHasVerifiedAutoRenewableTransactionDoesNotIncludeUnfinishedConsumableTransaction() async throws { + _ = try await self.createTransaction(productID: Self.consumable, finished: false) + let result = await self.fetcher.firstVerifiedAutoRenewableTransaction + expect(result) == nil + } - func createTransaction( - productID: String? = nil, - finished: Bool - ) async throws -> StoreTransaction { - return StoreTransaction( - sk2Transaction: try await self.simulateAnyPurchase(productID: productID, - finishTransaction: finished) - ) + // MARK: - firstVerifiedTransaction + + func testHasFirstVerifiedTransaction() async throws { + let transaction = try await self.createTransaction(finished: true) + let result = await self.fetcher.firstVerifiedTransaction + expect(result) == transaction + } + + func testDoesNotHaveFirstVerifiedTransaction() async throws { + let result = await self.fetcher.firstVerifiedTransaction + expect(result) == nil + } + + func testFirstVerifiedTransactionDoesNotIncludeFinishedConsumableTransaction() async throws { + _ = try await self.createTransaction(productID: Self.consumable, finished: true) + let result = await self.fetcher.firstVerifiedTransaction + expect(result) == nil } - func createTransactionForConsumableProduct(finished: Bool) async throws -> StoreTransaction { - return try await self.createTransaction(productID: Self.consumable, finished: finished) + func testHasVerifiedTransactionIncludesUnfinishedConsumableTransaction() async throws { + let transaction = try await self.createTransaction(productID: Self.consumable, + finished: false) + let result = await self.fetcher.firstVerifiedTransaction + expect(result) == transaction } +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +private extension StoreKit2TransactionFetcherTests { + static let product1 = "com.revenuecat.monthly_4.99.1_week_intro" static let product2 = "com.revenuecat.annual_39.99_no_trial" static let consumable = "com.revenuecat.consumable" diff --git a/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionListenerTests.swift b/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionListenerTests.swift index 429402e4e1..b5b9a803d3 100644 --- a/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionListenerTests.swift +++ b/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionListenerTests.swift @@ -77,10 +77,10 @@ class StoreKit2TransactionListenerTests: StoreKit2TransactionListenerBaseTests { let fakeTransaction = try await self.simulateAnyPurchase() let (isCancelled, transaction) = try await self.listener.handle( - purchaseResult: .success(.verified(fakeTransaction)) + purchaseResult: .success(fakeTransaction) ) expect(isCancelled) == false - expect(transaction) == fakeTransaction + expect(transaction?.sk2Transaction) == fakeTransaction.underlyingTransaction } func testIsCancelledIsTrueWhenPurchaseIsCancelled() async throws { @@ -109,7 +109,7 @@ class StoreKit2TransactionListenerTests: StoreKit2TransactionListenerBaseTests { let transaction = try await self.simulateAnyPurchase() let error: StoreKit.VerificationResult.VerificationError = .invalidSignature - let result: StoreKit.VerificationResult = .unverified(transaction, error) + let result: StoreKit.VerificationResult = .unverified(transaction.underlyingTransaction, error) // Note: can't use `expect().to(throwError)` or `XCTAssertThrowsError` // because neither of them accept `async` @@ -135,9 +135,9 @@ class StoreKit2TransactionListenerTests: StoreKit2TransactionListenerBaseTests { func testHandlePurchaseResultDoesNotFinishTransaction() async throws { let (purchaseResult, _, purchasedTransaction) = try await self.purchase() - let sk2Transaction = try await self.listener.handle(purchaseResult: purchaseResult) - expect(sk2Transaction.transaction) == purchasedTransaction - expect(sk2Transaction.userCancelled) == false + let resultData = try await self.listener.handle(purchaseResult: purchaseResult) + expect(resultData.transaction?.sk2Transaction) == purchasedTransaction + expect(resultData.userCancelled) == false try await self.verifyUnfinishedTransaction(withId: purchasedTransaction.id) } diff --git a/Tests/StoreKitUnitTests/StoreTransactionTests.swift b/Tests/StoreKitUnitTests/StoreTransactionTests.swift index d513524794..9c664157dc 100644 --- a/Tests/StoreKitUnitTests/StoreTransactionTests.swift +++ b/Tests/StoreKitUnitTests/StoreTransactionTests.swift @@ -63,6 +63,7 @@ class StoreTransactionTests: StoreKitConfigTestCase { expect(transaction.storefront).to(beNil()) expect(transaction.hasKnownPurchaseDate) == false expect(transaction.hasKnownTransactionIdentifier) == true + expect(transaction.jwsRepresentation).to(beNil()) } @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) @@ -70,8 +71,9 @@ class StoreTransactionTests: StoreKitConfigTestCase { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() let sk2Transaction = try await self.createTransactionWithPurchase() + let jwsRepresentation = UUID().uuidString - let transaction = StoreTransaction(sk2Transaction: sk2Transaction) + let transaction = StoreTransaction(sk2Transaction: sk2Transaction, jwsRepresentation: jwsRepresentation) // Can't use `===` because `SK2Transaction` is a `struct` expect(transaction.sk2Transaction) == sk2Transaction @@ -82,6 +84,7 @@ class StoreTransactionTests: StoreKitConfigTestCase { expect(transaction.quantity) == sk2Transaction.purchasedQuantity expect(transaction.hasKnownPurchaseDate) == true expect(transaction.hasKnownTransactionIdentifier) == true + expect(transaction.jwsRepresentation) == jwsRepresentation if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { let expected = await Storefront.currentStorefront diff --git a/Tests/StoreKitUnitTests/TestHelpers/StoreKitConfigTestCase+Extensions.swift b/Tests/StoreKitUnitTests/TestHelpers/StoreKitConfigTestCase+Extensions.swift index 35c207d99a..460347d3d2 100644 --- a/Tests/StoreKitUnitTests/TestHelpers/StoreKitConfigTestCase+Extensions.swift +++ b/Tests/StoreKitUnitTests/TestHelpers/StoreKitConfigTestCase+Extensions.swift @@ -35,7 +35,7 @@ extension StoreKitConfigTestCase { } return try await self.simulateAnyPurchase(product: product, - finishTransaction: finishTransaction) + finishTransaction: finishTransaction).underlyingTransaction } /// - Returns: `SK2Transaction` ater the purchase succeeded. @@ -44,7 +44,7 @@ extension StoreKitConfigTestCase { func simulateAnyPurchase( product: SK2Product? = nil, finishTransaction: Bool = false - ) async throws -> SK2Transaction { + ) async throws -> StoreKit.VerificationResult { let productToPurchase: SK2Product if let product = product { productToPurchase = product @@ -59,13 +59,13 @@ extension StoreKitConfigTestCase { await verificationResult.underlyingTransaction.finish() } - return verificationResult.underlyingTransaction + return verificationResult } /// - Returns: `SK2Transaction` after the purchase succeeded. This transaction is automatically finished. @MainActor func createTransactionWithPurchase(product: SK2Product? = nil) async throws -> Transaction { - return try await self.simulateAnyPurchase(product: product, finishTransaction: true) + return try await self.simulateAnyPurchase(product: product, finishTransaction: true).underlyingTransaction } @MainActor @@ -80,6 +80,27 @@ extension StoreKitConfigTestCase { return SK2StoreProduct(sk2Product: try await self.fetchSk2Product(productID)) } + @MainActor + func createTransaction( + productID: String? = nil, + finished: Bool + ) async throws -> StoreTransaction { + let product: SK2Product? + + if let productID = productID { + product = try await self.fetchSk2Product(productID) + } else { + product = nil + } + + let result = try await self.simulateAnyPurchase(product: product, + finishTransaction: finished) + return StoreTransaction( + sk2Transaction: result.underlyingTransaction, + jwsRepresentation: result.jwsRepresentation + ) + } + } @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) diff --git a/Tests/TestingApps/PurchaseTesterSwiftUI/Core/ConfiguredPurchases.swift b/Tests/TestingApps/PurchaseTesterSwiftUI/Core/ConfiguredPurchases.swift index 1ff8133f47..1adbecc9a7 100644 --- a/Tests/TestingApps/PurchaseTesterSwiftUI/Core/ConfiguredPurchases.swift +++ b/Tests/TestingApps/PurchaseTesterSwiftUI/Core/ConfiguredPurchases.swift @@ -7,7 +7,11 @@ import Foundation +#if DEBUG +@testable import RevenueCat +#else import RevenueCat +#endif public final class ConfiguredPurchases { @@ -44,6 +48,9 @@ public final class ConfiguredPurchases { .with(usesStoreKit2IfAvailable: useStoreKit2) .with(observerMode: observerMode) .with(entitlementVerificationMode: entitlementVerificationMode) + #if DEBUG + .with(dangerousSettings: .init(autoSyncPurchases: true, internalSettings: DangerousSettings.Internal(usesStoreKit2JWS: useStoreKit2))) + #endif .build() ) diff --git a/Tests/UnitTests/Mocks/MockBackend.swift b/Tests/UnitTests/Mocks/MockBackend.swift index f26b3368db..29596d6a2c 100644 --- a/Tests/UnitTests/Mocks/MockBackend.swift +++ b/Tests/UnitTests/Mocks/MockBackend.swift @@ -8,7 +8,7 @@ // swiftlint:disable large_tuple line_length class MockBackend: Backend { - typealias PostReceiptParameters = (data: Data?, + typealias PostReceiptParameters = (data: EncodedAppleReceipt?, productData: ProductRequestData?, transactionData: PurchasedTransactionData, observerMode: Bool, @@ -41,19 +41,19 @@ class MockBackend: Backend { internalAPI: internalAPI) } - override func post(receiptData: Data, + override func post(receipt: EncodedAppleReceipt, productData: ProductRequestData?, transactionData: PurchasedTransactionData, observerMode: Bool, completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { invokedPostReceiptData = true invokedPostReceiptDataCount += 1 - invokedPostReceiptDataParameters = (receiptData, + invokedPostReceiptDataParameters = (receipt, productData, transactionData, observerMode, completion) - invokedPostReceiptDataParametersList.append((receiptData, + invokedPostReceiptDataParametersList.append((receipt, productData, transactionData, observerMode, diff --git a/Tests/UnitTests/Mocks/MockOfferingsAPI.swift b/Tests/UnitTests/Mocks/MockOfferingsAPI.swift index bd69c4f4c3..72eef7d03b 100644 --- a/Tests/UnitTests/Mocks/MockOfferingsAPI.swift +++ b/Tests/UnitTests/Mocks/MockOfferingsAPI.swift @@ -56,11 +56,11 @@ class MockOfferingsAPI: OfferingsAPI { var invokedPostOffer = false var invokedPostOfferCount = 0 - var invokedPostOfferParameters: (offerIdentifier: String?, productIdentifier: String?, subscriptionGroup: String?, data: Data?, applicationUsername: String?, completion: OfferingsAPI.OfferSigningResponseHandler?)? + var invokedPostOfferParameters: (offerIdentifier: String?, productIdentifier: String?, subscriptionGroup: String?, data: EncodedAppleReceipt?, applicationUsername: String?, completion: OfferingsAPI.OfferSigningResponseHandler?)? var invokedPostOfferParametersList = [(offerIdentifier: String?, productIdentifier: String?, subscriptionGroup: String?, - data: Data?, + data: EncodedAppleReceipt?, applicationUsername: String?, completion: OfferingsAPI.OfferSigningResponseHandler?)]() var stubbedPostOfferCompletionResult: Result? @@ -68,7 +68,7 @@ class MockOfferingsAPI: OfferingsAPI { override func post(offerIdForSigning offerIdentifier: String, productIdentifier: String, subscriptionGroup: String?, - receiptData: Data, + receipt: EncodedAppleReceipt, appUserID: String, completion: @escaping OfferingsAPI.OfferSigningResponseHandler) { self.invokedPostOffer = true @@ -76,13 +76,13 @@ class MockOfferingsAPI: OfferingsAPI { self.invokedPostOfferParameters = (offerIdentifier, productIdentifier, subscriptionGroup, - receiptData, + receipt, appUserID, completion) self.invokedPostOfferParametersList.append((offerIdentifier, productIdentifier, subscriptionGroup, - receiptData, + receipt, appUserID, completion)) diff --git a/Tests/UnitTests/Mocks/MockStoreKit2TransactionFetcher.swift b/Tests/UnitTests/Mocks/MockStoreKit2TransactionFetcher.swift index 6e1ab12d74..58f62772d6 100644 --- a/Tests/UnitTests/Mocks/MockStoreKit2TransactionFetcher.swift +++ b/Tests/UnitTests/Mocks/MockStoreKit2TransactionFetcher.swift @@ -17,6 +17,8 @@ import Foundation final class MockStoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { private let _stubbedUnfinishedTransactions: Atomic<[StoreTransaction]> = .init([]) + private let _stubbedFirstVerifiedTransaction: Atomic = .init(nil) + private let _stubbedFirstVerifiedAutoRenewableTransaction: Atomic = .init(nil) private let _stubbedHasPendingConsumablePurchase: Atomic = false var stubbedUnfinishedTransactions: [StoreTransaction] { @@ -24,6 +26,16 @@ final class MockStoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { set { self._stubbedUnfinishedTransactions.value = newValue } } + var stubbedFirstVerifiedTransaction: StoreTransaction? { + get { return self._stubbedFirstVerifiedTransaction.value } + set { self._stubbedFirstVerifiedTransaction.value = newValue } + } + + var stubbedFirstVerifiedAutoRenewableTransaction: StoreTransaction? { + get { return self._stubbedFirstVerifiedAutoRenewableTransaction.value } + set { self._stubbedFirstVerifiedAutoRenewableTransaction.value = newValue } + } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) var unfinishedVerifiedTransactions: [StoreTransaction] { get async { @@ -31,6 +43,20 @@ final class MockStoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { } } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var firstVerifiedTransaction: RevenueCat.StoreTransaction? { + get async { + self.stubbedFirstVerifiedTransaction + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var firstVerifiedAutoRenewableTransaction: RevenueCat.StoreTransaction? { + get async { + self.stubbedFirstVerifiedAutoRenewableTransaction + } + } + // MARK: - var stubbedHasPendingConsumablePurchase: Bool { diff --git a/Tests/UnitTests/Mocks/MockStoreKit2TransactionListener.swift b/Tests/UnitTests/Mocks/MockStoreKit2TransactionListener.swift index 2e1d8be181..935d1c7a1f 100644 --- a/Tests/UnitTests/Mocks/MockStoreKit2TransactionListener.swift +++ b/Tests/UnitTests/Mocks/MockStoreKit2TransactionListener.swift @@ -60,7 +60,11 @@ final class MockStoreKit2TransactionListener: StoreKit2TransactionListenerType { self.invokedHandleParameters = (.init(purchaseResult), ()) self.invokedHandleParametersList.append((.init(purchaseResult), ())) - return (self.mockCancelled, self.mockTransaction.value) + let transaction: StoreTransaction? = self.mockTransaction.value.map { + StoreTransaction(sk2Transaction: $0, jwsRepresentation: "") + } + + return (self.mockCancelled, transaction) } } diff --git a/Tests/UnitTests/Mocks/MockStoreTransaction.swift b/Tests/UnitTests/Mocks/MockStoreTransaction.swift index 3809895bfc..a256ae6518 100644 --- a/Tests/UnitTests/Mocks/MockStoreTransaction.swift +++ b/Tests/UnitTests/Mocks/MockStoreTransaction.swift @@ -26,13 +26,15 @@ final class MockStoreTransaction: StoreTransactionType { let transactionIdentifier: String let quantity: Int let storefront: RCStorefront? + let jwsRepresentation: String? - init() { + init(jwsRepresentation: String? = nil) { self.productIdentifier = UUID().uuidString self.purchaseDate = Date() self.transactionIdentifier = UUID().uuidString self.quantity = 1 self.storefront = nil + self.jwsRepresentation = jwsRepresentation } private let _hasKnownPurchaseDate: Atomic = true diff --git a/Tests/UnitTests/Mocks/MockSystemInfo.swift b/Tests/UnitTests/Mocks/MockSystemInfo.swift index 02a374228e..2b447724e8 100644 --- a/Tests/UnitTests/Mocks/MockSystemInfo.swift +++ b/Tests/UnitTests/Mocks/MockSystemInfo.swift @@ -16,12 +16,18 @@ class MockSystemInfo: SystemInfo { var stubbedIsApplicationBackgrounded: Bool? var stubbedIsSandbox: Bool? - convenience init(finishTransactions: Bool, + convenience init(platformInfo: Purchases.PlatformInfo? = nil, + finishTransactions: Bool, storeKit2Setting: StoreKit2Setting = .default, customEntitlementsComputation: Bool = false, + usesStoreKit2JWS: Bool = false, clock: ClockType = TestClock()) { - let dangerousSettings = DangerousSettings(customEntitlementComputation: customEntitlementsComputation) - self.init(platformInfo: nil, + let dangerousSettings = DangerousSettings( + autoSyncPurchases: true, + customEntitlementComputation: customEntitlementsComputation, + internalSettings: DangerousSettings.Internal(usesStoreKit2JWS: usesStoreKit2JWS) + ) + self.init(platformInfo: platformInfo, finishTransactions: finishTransactions, storeKit2Setting: storeKit2Setting, dangerousSettings: dangerousSettings, diff --git a/Tests/UnitTests/Networking/Backend/BackendPostOfferForSigningTests.swift b/Tests/UnitTests/Networking/Backend/BackendPostOfferForSigningTests.swift index c81e253069..20f899dbb9 100644 --- a/Tests/UnitTests/Networking/Backend/BackendPostOfferForSigningTests.swift +++ b/Tests/UnitTests/Networking/Backend/BackendPostOfferForSigningTests.swift @@ -48,13 +48,12 @@ class BackendPostOfferForSigningTests: BaseBackendTests { let productIdentifier = "a_great_product" let group = "sub_group" let offerIdentifier = "offerid" - let discountData = "an awesome discount".asData waitUntil { completed in self.offerings.post(offerIdForSigning: offerIdentifier, productIdentifier: productIdentifier, subscriptionGroup: group, - receiptData: discountData, + receipt: .receipt("an awesome discount".asData), appUserID: Self.userID) { _ in completed() } @@ -74,13 +73,13 @@ class BackendPostOfferForSigningTests: BaseBackendTests { let productIdentifier = "a_great_product" let group = "sub_group" let offerIdentifier = "offerid" - let discountData = "an awesome discount".data(using: String.Encoding.utf8)! + let receipt = EncodedAppleReceipt.receipt("an awesome discount".data(using: String.Encoding.utf8)!) let result = waitUntilValue { completed in self.offerings.post(offerIdForSigning: offerIdentifier, productIdentifier: productIdentifier, subscriptionGroup: group, - receiptData: discountData, + receipt: receipt, appUserID: Self.userID, completion: completed) } @@ -102,13 +101,13 @@ class BackendPostOfferForSigningTests: BaseBackendTests { let productIdentifier = "a_great_product" let group = "sub_group" let offerIdentifier = "offerid" - let discountData = "an awesome discount".data(using: String.Encoding.utf8)! + let receipt = EncodedAppleReceipt.receipt("an awesome discount".data(using: String.Encoding.utf8)!) let result = waitUntilValue { completed in self.offerings.post(offerIdForSigning: offerIdentifier, productIdentifier: productIdentifier, subscriptionGroup: group, - receiptData: discountData, + receipt: receipt, appUserID: Self.userID, completion: completed) } @@ -145,13 +144,13 @@ class BackendPostOfferForSigningTests: BaseBackendTests { let productIdentifier = "a_great_product" let group = "sub_group" let offerIdentifier = "offerid" - let discountData = "an awesome discount".data(using: String.Encoding.utf8)! + let receipt = EncodedAppleReceipt.receipt("an awesome discount".data(using: String.Encoding.utf8)!) let result = waitUntilValue { completed in self.offerings.post(offerIdForSigning: offerIdentifier, productIdentifier: productIdentifier, subscriptionGroup: group, - receiptData: discountData, + receipt: receipt, appUserID: Self.userID, completion: completed) } @@ -181,13 +180,13 @@ class BackendPostOfferForSigningTests: BaseBackendTests { let productIdentifier = "a_great_product" let group = "sub_group" let offerIdentifier = "offerid" - let discountData = "an awesome discount".data(using: String.Encoding.utf8)! + let receipt = EncodedAppleReceipt.receipt("an awesome discount".data(using: String.Encoding.utf8)!) let result = waitUntilValue { completed in self.offerings.post(offerIdForSigning: offerIdentifier, productIdentifier: productIdentifier, subscriptionGroup: group, - receiptData: discountData, + receipt: receipt, appUserID: Self.userID, completion: completed) } diff --git a/Tests/UnitTests/Networking/Backend/BackendPostReceiptDataTests.swift b/Tests/UnitTests/Networking/Backend/BackendPostReceiptDataTests.swift index ee01c09370..778c33a61a 100644 --- a/Tests/UnitTests/Networking/Backend/BackendPostReceiptDataTests.swift +++ b/Tests/UnitTests/Networking/Backend/BackendPostReceiptDataTests.swift @@ -39,7 +39,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { let observerMode = true waitUntil { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -70,7 +70,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { let productData: ProductRequestData = .createMockProductData(currencyCode: "USD") waitUntil { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: productData, transactionData: .init( appUserID: Self.userID, @@ -110,7 +110,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { ) waitUntil { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: .createMockProductData(), transactionData: .init( appUserID: Self.userID, @@ -139,7 +139,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { let isRestore = true let observerMode = false - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -152,7 +152,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { completionCalled.value += 1 } - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -182,7 +182,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { let isRestore = false let observerMode = false - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -196,7 +196,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { completionCalled.value += 1 }) - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -225,7 +225,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { let isRestore = true let observerMode = true - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -239,7 +239,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { completionCalled.value += 1 }) - backend.post(receiptData: Self.receiptData2, + backend.post(receipt: Self.receipt2, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -268,7 +268,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { let isRestore = false let observerMode = true - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -283,7 +283,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { }) let productData: ProductRequestData = .createMockProductData(currencyCode: "USD") - backend.post(receiptData: Self.receiptData2, + backend.post(receipt: Self.receipt2, productData: productData, transactionData: .init( appUserID: Self.userID, @@ -312,7 +312,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { let isRestore = true let observerMode = false - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -326,7 +326,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { completionCalled.value += 1 }) - backend.post(receiptData: Self.receiptData2, + backend.post(receipt: Self.receipt2, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -366,7 +366,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { subscriptionGroup: group) waitUntil { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: productData, transactionData: .init( appUserID: Self.userID, @@ -414,7 +414,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { subscriptionGroup: group) waitUntil { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: productData, transactionData: .init( appUserID: Self.userID, @@ -443,7 +443,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { let productData: ProductRequestData = .createMockProductData() waitUntil { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: productData, transactionData: .init( appUserID: Self.userID, @@ -539,7 +539,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { self.httpClient.mocks.removeValue(forKey: getCustomerInfoPath.url!) } - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -579,7 +579,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { ) let customerInfo = waitUntilValue { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -606,7 +606,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { ) let receivedError = waitUntilValue { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -635,7 +635,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { let isRestore = true let observerMode = false - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -658,7 +658,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { numberOfPeriods: 1, type: .promotional) let productData: ProductRequestData = .createMockProductData(discounts: [discount]) - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: productData, transactionData: .init( appUserID: Self.userID, @@ -704,7 +704,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { discounts: [discount]) waitUntil { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: productData, transactionData: .init( appUserID: Self.userID, @@ -732,7 +732,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { let isRestore = false let observerMode = true - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -746,7 +746,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { completionCalled.value += 1 }) - backend.post(receiptData: Self.receiptData, + backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -777,7 +777,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { self.mockOfflineCustomerInfoCreator.stubbedCreatedResult = .success(customerInfo) let result = waitUntilValue { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -812,7 +812,7 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { self.mockOfflineCustomerInfoCreator.stubbedCreatedResult = .success(customerInfo) let result = waitUntilValue { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: .createMockProductData(), transactionData: .init( appUserID: Self.userID, @@ -834,6 +834,37 @@ class BackendPostReceiptDataTests: BaseBackendPostReceiptDataTests { expect(self.mockOfflineCustomerInfoCreator.createRequestCount) == 1 } + func testPostsJWSTokenWithProductDataCorrectly() throws { + let path: HTTPRequest.Path = .postReceiptData + + httpClient.mock( + requestPath: path, + response: .init(statusCode: .success, response: Self.validCustomerResponse) + ) + + let isRestore = false + let observerMode = true + let productData: ProductRequestData = .createMockProductData(currencyCode: "USD") + + waitUntil { completed in + self.backend.post(receipt: Self.jws, + productData: productData, + transactionData: .init( + appUserID: Self.userID, + presentedOfferingID: nil, + unsyncedAttributes: nil, + storefront: nil, + source: .init(isRestore: isRestore, initiationSource: .purchase) + ), + observerMode: observerMode, + completion: { _ in + completed() + }) + } + + expect(self.httpClient.calls).to(haveCount(1)) + } + } // swiftlint:disable:next type_name @@ -850,7 +881,7 @@ class BackendPostReceiptWithSignatureVerificationTests: BaseBackendPostReceiptDa ) let result = waitUntilValue { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -881,7 +912,7 @@ class BackendPostReceiptWithSignatureVerificationTests: BaseBackendPostReceiptDa ) let result = waitUntilValue { completed in - self.backend.post(receiptData: Self.receiptData2, + self.backend.post(receipt: Self.receipt2, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -925,7 +956,7 @@ class BackendPostReceiptCustomEntitlementsTests: BaseBackendPostReceiptDataTests ) waitUntil { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: nil, transactionData: .init( appUserID: Self.userID, @@ -947,14 +978,15 @@ class BackendPostReceiptCustomEntitlementsTests: BaseBackendPostReceiptDataTests private extension BaseBackendPostReceiptDataTests { - static let receiptData = "an awesome receipt".asData - static let receiptData2 = "an awesomeer receipt".asData + static let receipt = EncodedAppleReceipt.receipt("an awesome receipt".asData) + static let receipt2 = EncodedAppleReceipt.receipt("an awesomeer receipt".asData) + static let jws = EncodedAppleReceipt.jws("an awesomer jws token") func postPaymentMode(paymentMode: StoreProductDiscount.PaymentMode) { let productData: ProductRequestData = .createMockProductData(paymentMode: paymentMode) waitUntil { completed in - self.backend.post(receiptData: Self.receiptData, + self.backend.post(receipt: Self.receipt, productData: productData, transactionData: .init( appUserID: Self.userID, diff --git a/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS12-testPostsJWSTokenWithProductDataCorrectly.1.json b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS12-testPostsJWSTokenWithProductDataCorrectly.1.json new file mode 100644 index 0000000000..1dc2aed2a8 --- /dev/null +++ b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS12-testPostsJWSTokenWithProductDataCorrectly.1.json @@ -0,0 +1,26 @@ +{ + "headers" : { + "Authorization" : "Bearer asharedsecret" + }, + "request" : { + "body" : { + "app_user_id" : "user", + "attributes" : { + "$attConsentStatus" : { + "updated_at_ms" : 1678307200000, + "value" : "notDetermined" + } + }, + "currency" : "USD", + "fetch_token" : "an awesomer jws token", + "initiation_source" : "purchase", + "is_restore" : false, + "observer_mode" : true, + "price" : "15.99", + "product_id" : "product_id", + "store_country" : "ESP" + }, + "method" : "POST", + "url" : "https:\/\/api.revenuecat.com\/v1\/receipts" + } +} \ No newline at end of file diff --git a/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS13-testPostsJWSTokenWithProductDataCorrectly.1.json b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS13-testPostsJWSTokenWithProductDataCorrectly.1.json new file mode 100644 index 0000000000..792b9d83d2 --- /dev/null +++ b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS13-testPostsJWSTokenWithProductDataCorrectly.1.json @@ -0,0 +1,26 @@ +{ + "headers" : { + "Authorization" : "Bearer asharedsecret" + }, + "request" : { + "body" : { + "app_user_id" : "user", + "attributes" : { + "$attConsentStatus" : { + "updated_at_ms" : 1678307200000, + "value" : "notDetermined" + } + }, + "currency" : "USD", + "fetch_token" : "an awesomer jws token", + "initiation_source" : "purchase", + "is_restore" : false, + "observer_mode" : true, + "price" : "15.99", + "product_id" : "product_id", + "store_country" : "ESP" + }, + "method" : "POST", + "url" : "https://api.revenuecat.com/v1/receipts" + } +} \ No newline at end of file diff --git a/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS14-testPostsJWSTokenWithProductDataCorrectly.1.json b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS14-testPostsJWSTokenWithProductDataCorrectly.1.json new file mode 100644 index 0000000000..0da7a29618 --- /dev/null +++ b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS14-testPostsJWSTokenWithProductDataCorrectly.1.json @@ -0,0 +1,26 @@ +{ + "headers" : { + "Authorization" : "Bearer asharedsecret" + }, + "request" : { + "body" : { + "app_user_id" : "user", + "attributes" : { + "$attConsentStatus" : { + "updated_at_ms" : 1678307200000, + "value" : "authorized" + } + }, + "currency" : "USD", + "fetch_token" : "an awesomer jws token", + "initiation_source" : "purchase", + "is_restore" : false, + "observer_mode" : true, + "price" : "15.99", + "product_id" : "product_id", + "store_country" : "ESP" + }, + "method" : "POST", + "url" : "https://api.revenuecat.com/v1/receipts" + } +} \ No newline at end of file diff --git a/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS15-testPostsJWSTokenWithProductDataCorrectly.1.json b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS15-testPostsJWSTokenWithProductDataCorrectly.1.json new file mode 100644 index 0000000000..0da7a29618 --- /dev/null +++ b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS15-testPostsJWSTokenWithProductDataCorrectly.1.json @@ -0,0 +1,26 @@ +{ + "headers" : { + "Authorization" : "Bearer asharedsecret" + }, + "request" : { + "body" : { + "app_user_id" : "user", + "attributes" : { + "$attConsentStatus" : { + "updated_at_ms" : 1678307200000, + "value" : "authorized" + } + }, + "currency" : "USD", + "fetch_token" : "an awesomer jws token", + "initiation_source" : "purchase", + "is_restore" : false, + "observer_mode" : true, + "price" : "15.99", + "product_id" : "product_id", + "store_country" : "ESP" + }, + "method" : "POST", + "url" : "https://api.revenuecat.com/v1/receipts" + } +} \ No newline at end of file diff --git a/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS16-testPostsJWSTokenWithProductDataCorrectly.1.json b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS16-testPostsJWSTokenWithProductDataCorrectly.1.json new file mode 100644 index 0000000000..0da7a29618 --- /dev/null +++ b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS16-testPostsJWSTokenWithProductDataCorrectly.1.json @@ -0,0 +1,26 @@ +{ + "headers" : { + "Authorization" : "Bearer asharedsecret" + }, + "request" : { + "body" : { + "app_user_id" : "user", + "attributes" : { + "$attConsentStatus" : { + "updated_at_ms" : 1678307200000, + "value" : "authorized" + } + }, + "currency" : "USD", + "fetch_token" : "an awesomer jws token", + "initiation_source" : "purchase", + "is_restore" : false, + "observer_mode" : true, + "price" : "15.99", + "product_id" : "product_id", + "store_country" : "ESP" + }, + "method" : "POST", + "url" : "https://api.revenuecat.com/v1/receipts" + } +} \ No newline at end of file diff --git a/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS17-testPostsJWSTokenWithProductDataCorrectly.1.json b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS17-testPostsJWSTokenWithProductDataCorrectly.1.json new file mode 100644 index 0000000000..0da7a29618 --- /dev/null +++ b/Tests/UnitTests/Networking/Backend/__Snapshots__/BackendPostReceiptDataTests/iOS17-testPostsJWSTokenWithProductDataCorrectly.1.json @@ -0,0 +1,26 @@ +{ + "headers" : { + "Authorization" : "Bearer asharedsecret" + }, + "request" : { + "body" : { + "app_user_id" : "user", + "attributes" : { + "$attConsentStatus" : { + "updated_at_ms" : 1678307200000, + "value" : "authorized" + } + }, + "currency" : "USD", + "fetch_token" : "an awesomer jws token", + "initiation_source" : "purchase", + "is_restore" : false, + "observer_mode" : true, + "price" : "15.99", + "product_id" : "product_id", + "store_country" : "ESP" + }, + "method" : "POST", + "url" : "https://api.revenuecat.com/v1/receipts" + } +} \ No newline at end of file diff --git a/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift b/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift index 2a8d1a6510..24386a281b 100644 --- a/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift @@ -50,6 +50,7 @@ class BasePurchasesTests: TestCase { self.paywallEventsManager = nil } self.requestFetcher = MockRequestFetcher() + self.purchasedProductsFetcher = .init() self.mockProductsManager = MockProductsManager(systemInfo: self.systemInfo, requestTimeout: Configuration.storeKitRequestTimeoutDefault) self.mockOperationDispatcher = MockOperationDispatcher() @@ -142,6 +143,7 @@ class BasePurchasesTests: TestCase { var receiptFetcher: MockReceiptFetcher! var requestFetcher: MockRequestFetcher! var mockProductsManager: MockProductsManager! + var purchasedProductsFetcher: MockPurchasedProductsFetcher! var backend: MockBackend! var storeKit1Wrapper: MockStoreKit1Wrapper! var mockPaymentQueueWrapper: MockPaymentQueueWrapper! @@ -238,6 +240,7 @@ class BasePurchasesTests: TestCase { operationDispatcher: self.mockOperationDispatcher, receiptFetcher: self.receiptFetcher, receiptParser: self.mockReceiptParser, + transactionFetcher: self.mockTransactionFetcher, customerInfoManager: self.customerInfoManager, backend: self.backend, transactionPoster: self.transactionPoster, @@ -379,7 +382,7 @@ extension BasePurchasesTests { override func post(offerIdForSigning offerIdentifier: String, productIdentifier: String, subscriptionGroup: String?, - receiptData: Data, + receipt: EncodedAppleReceipt, appUserID: String, completion: @escaping OfferingsAPI.OfferSigningResponseHandler) { self.postOfferForSigningCalled = true @@ -423,7 +426,7 @@ extension BasePurchasesTests { } var postReceiptDataCalled = false - var postedReceiptData: Data? + var postedReceiptData: EncodedAppleReceipt? var postedIsRestore: Bool? var postedProductID: String? var postedPrice: Decimal? @@ -437,13 +440,13 @@ extension BasePurchasesTests { var postedInitiationSource: ProductRequestData.InitiationSource? var postReceiptResult: Result? - override func post(receiptData: Data, + override func post(receipt: EncodedAppleReceipt, productData: ProductRequestData?, transactionData: PurchasedTransactionData, observerMode: Bool, completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { self.postReceiptDataCalled = true - self.postedReceiptData = receiptData + self.postedReceiptData = receipt self.postedIsRestore = transactionData.source.isRestore if let productData = productData { diff --git a/Tests/UnitTests/Purchasing/Purchases/PurchasesRestoreTests.swift b/Tests/UnitTests/Purchasing/Purchases/PurchasesRestoreTests.swift index 219fc86f7e..63cc69b5c6 100644 --- a/Tests/UnitTests/Purchasing/Purchases/PurchasesRestoreTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/PurchasesRestoreTests.swift @@ -169,7 +169,7 @@ class PurchasesRestoreTests: BasePurchasesTests { expect(self.receiptFetcher.receiptDataTimesCalled) == 1 - expect(self.backend.postedReceiptData) == self.receiptFetcher.mockReceiptData + expect(self.backend.postedReceiptData) == EncodedAppleReceipt.receipt(self.receiptFetcher.mockReceiptData) expect(self.backend.postedProductID) == productIdentifier expect(self.backend.postedPrice) == product.price as Decimal expect(self.backend.postedCurrencyCode) == "USD" diff --git a/Tests/UnitTests/Purchasing/Purchases/TransactionPosterTests.swift b/Tests/UnitTests/Purchasing/Purchases/TransactionPosterTests.swift index 7f4e5d8606..0f67ac5553 100644 --- a/Tests/UnitTests/Purchasing/Purchases/TransactionPosterTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/TransactionPosterTests.swift @@ -19,6 +19,7 @@ import XCTest class TransactionPosterTests: TestCase { private var productsManager: MockProductsManager! + private var transactionFetcher: MockStoreKit2TransactionFetcher! private var receiptFetcher: MockReceiptFetcher! private var backend: MockBackend! private var paymentQueueWrapper: MockPaymentQueueWrapper! @@ -73,6 +74,59 @@ class TransactionPosterTests: TestCase { expect(self.mockTransaction.finishInvoked) == true } + func testHandlePurchasedTransactionSendsReceiptIfJWSSettingEnabledButJWSTokenIsMissing() throws { + self.setUp(observerMode: false, usesStoreKit2JWS: true) + + let product = MockSK1Product(mockProductIdentifier: "product") + let transactionData = PurchasedTransactionData( + appUserID: "user", + source: .init(isRestore: false, initiationSource: .queue) + ) + + let receiptData = "mock receipt".asData + self.receiptFetcher.shouldReturnReceipt = true + self.receiptFetcher.mockReceiptData = receiptData + self.productsManager.stubbedProductsCompletionResult = .success([StoreProduct(sk1Product: product)]) + self.backend.stubbedPostReceiptResult = .success(Self.mockCustomerInfo) + + let result = try self.handleTransaction(transactionData) + expect(result).to(beSuccess()) + expect(result.value) === Self.mockCustomerInfo + + expect(self.backend.invokedPostReceiptData) == true + expect(self.backend.invokedPostReceiptDataParameters?.transactionData).to(match(transactionData)) + expect(self.backend.invokedPostReceiptDataParameters?.data) == .receipt(receiptData) + expect(self.backend.invokedPostReceiptDataParameters?.observerMode) == self.systemInfo.observerMode + expect(self.mockTransaction.finishInvoked) == true + } + + func testHandlePurchasedTransactionSendsJWS() throws { + self.setUp(observerMode: false, usesStoreKit2JWS: true) + let jwsRepresentation = UUID().uuidString + self.mockTransaction = MockStoreTransaction(jwsRepresentation: jwsRepresentation) + + let product = MockSK1Product(mockProductIdentifier: "product") + + let transactionData = PurchasedTransactionData( + appUserID: "user", + source: .init(isRestore: false, initiationSource: .queue) + ) + + self.receiptFetcher.shouldReturnReceipt = false + self.productsManager.stubbedProductsCompletionResult = .success([StoreProduct(sk1Product: product)]) + self.backend.stubbedPostReceiptResult = .success(Self.mockCustomerInfo) + + let result = try self.handleTransaction(transactionData) + expect(result).to(beSuccess()) + expect(result.value) === Self.mockCustomerInfo + + expect(self.backend.invokedPostReceiptData) == true + expect(self.backend.invokedPostReceiptDataParameters?.transactionData).to(match(transactionData)) + expect(self.backend.invokedPostReceiptDataParameters?.data) == .jws(jwsRepresentation) + expect(self.backend.invokedPostReceiptDataParameters?.observerMode) == self.systemInfo.observerMode + expect(self.mockTransaction.finishInvoked) == true + } + func testHandlePurchasedTransactionDoesNotFinishNonProcessedConsumables() throws { let product = Self.createTestProduct(.consumable) let transactionData = PurchasedTransactionData( @@ -259,9 +313,9 @@ class TransactionPosterTests: TestCase { private extension TransactionPosterTests { - func setUp(observerMode: Bool) { + func setUp(observerMode: Bool, usesStoreKit2JWS: Bool = false) { self.operationDispatcher = .init() - self.systemInfo = .init(finishTransactions: !observerMode) + self.systemInfo = .init(finishTransactions: !observerMode, usesStoreKit2JWS: usesStoreKit2JWS) self.productsManager = .init(systemInfo: self.systemInfo, requestTimeout: 0) self.receiptFetcher = .init(requestFetcher: .init(operationDispatcher: self.operationDispatcher), systemInfo: self.systemInfo) diff --git a/Tests/UnitTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift b/Tests/UnitTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift index fc92729e10..7d6a336f00 100644 --- a/Tests/UnitTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift +++ b/Tests/UnitTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift @@ -21,7 +21,7 @@ class BackendSubscriberAttributesTests: TestCase { let appUserID = "abc123" let referenceDate = Date(timeIntervalSinceReferenceDate: 700000000) // 2023-03-08 20:26:40 - let receiptData = "an awesome receipt".data(using: String.Encoding.utf8)! + let receipt = EncodedAppleReceipt.receipt("an awesome receipt".data(using: String.Encoding.utf8)!) var subscriberAttribute1: SubscriberAttribute! var subscriberAttribute2: SubscriberAttribute! @@ -82,7 +82,7 @@ class BackendSubscriberAttributesTests: TestCase { subscriberAttribute2.key: subscriberAttribute2 ] - backend.post(receiptData: self.receiptData, + backend.post(receipt: self.receipt, productData: nil, transactionData: .init( appUserID: self.appUserID, @@ -100,7 +100,7 @@ class BackendSubscriberAttributesTests: TestCase { let token = "token" waitUntil { completion in - self.backend.post(receiptData: self.receiptData, + self.backend.post(receipt: self.receipt, productData: nil, transactionData: .init( appUserID: self.appUserID, @@ -128,7 +128,7 @@ class BackendSubscriberAttributesTests: TestCase { // No mocked response, the default response is an empty 200. - backend.post(receiptData: self.receiptData, + backend.post(receipt: self.receipt, productData: nil, transactionData: .init( appUserID: self.appUserID, @@ -152,7 +152,7 @@ class BackendSubscriberAttributesTests: TestCase { } func testPostReceiptWithoutSubscriberAttributesSkipsThem() throws { - backend.post(receiptData: self.receiptData, + backend.post(receipt: self.receipt, productData: nil, transactionData: .init( appUserID: self.appUserID, @@ -195,7 +195,7 @@ class BackendSubscriberAttributesTests: TestCase { let receivedCustomerInfo: CustomerInfo? = waitUntilValue { completion in self.backend.post( - receiptData: self.receiptData, + receipt: self.receipt, productData: nil, transactionData: .init( appUserID: self.appUserID, @@ -249,7 +249,7 @@ class BackendSubscriberAttributesTests: TestCase { let receivedError: BackendError? = waitUntilValue { completion in self.backend.post( - receiptData: self.receiptData, + receipt: self.receipt, productData: nil, transactionData: .init( appUserID: self.appUserID, diff --git a/Tests/UnitTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift b/Tests/UnitTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift index f97773772f..52dbcaca8f 100644 --- a/Tests/UnitTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift +++ b/Tests/UnitTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift @@ -60,6 +60,7 @@ class PurchasesSubscriberAttributesTests: TestCase { var mockOfferingsManager: MockOfferingsManager! var mockOfflineEntitlementsManager: MockOfflineEntitlementsManager! var mockPurchasedProductsFetcher: MockPurchasedProductsFetcher! + var mockTransactionFetcher: MockStoreKit2TransactionFetcher! var mockManageSubsHelper: MockManageSubscriptionsHelper! var mockBeginRefundRequestHelper: MockBeginRefundRequestHelper! var mockStoreMessagesHelper: MockStoreMessagesHelper! @@ -114,6 +115,7 @@ class PurchasesSubscriberAttributesTests: TestCase { systemInfo: self.systemInfo) self.mockOfflineEntitlementsManager = MockOfflineEntitlementsManager() self.mockPurchasedProductsFetcher = MockPurchasedProductsFetcher() + self.mockTransactionFetcher = MockStoreKit2TransactionFetcher() self.mockReceiptFetcher = MockReceiptFetcher( requestFetcher: self.mockRequestFetcher, systemInfo: systemInfoAttribution @@ -173,6 +175,7 @@ class PurchasesSubscriberAttributesTests: TestCase { operationDispatcher: self.mockOperationDispatcher, receiptFetcher: self.mockReceiptFetcher, receiptParser: self.mockReceiptParser, + transactionFetcher: self.mockTransactionFetcher, customerInfoManager: self.customerInfoManager, backend: self.mockBackend, transactionPoster: self.transactionPoster,