From bd3404b7ab8ecdbf2ebe9a5be412576cd616bccf Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Thu, 6 Oct 2022 15:22:15 -0700 Subject: [PATCH 1/4] `Integration Tests`: added tests for purchasing consumable products The second test reproduces #1964. --- .../StoreKitIntegrationTests.swift | 33 +++++++++++++++++++ ...rationPurchaseTesterConfiguration.storekit | 15 +++++++++ 2 files changed, 48 insertions(+) diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index 59198085f0..842dbf6385 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -68,6 +68,27 @@ class StoreKit1IntegrationTests: BaseBackendIntegrationTests { try await self.purchaseMonthlyProduct() } + func testCanPurchaseConsumable() async throws { + let info = try await self.purchaseConsumablePackage().customerInfo + + expect(info.allPurchasedProductIdentifiers).to(contain(Self.consumable10Coins)) + } + + func testCanPurchaseConsumableMultipleTimes() async throws { + let count = 2 + + for _ in 0.. PurchaseResultData { + let offering = try await XCTAsyncUnwrap(try await Purchases.shared.offerings().offering(identifier: "coins")) + let package = try XCTUnwrap(offering.package(identifier: "10.coins")) + + return try await Purchases.shared.purchase(package: package) + } + @discardableResult func verifyEntitlementWentThrough( _ customerInfo: CustomerInfo, diff --git a/Tests/TestingApps/PurchaseTester/RevenueCat_IntegrationPurchaseTesterConfiguration.storekit b/Tests/TestingApps/PurchaseTester/RevenueCat_IntegrationPurchaseTesterConfiguration.storekit index da62d85564..871b9872b0 100644 --- a/Tests/TestingApps/PurchaseTester/RevenueCat_IntegrationPurchaseTesterConfiguration.storekit +++ b/Tests/TestingApps/PurchaseTester/RevenueCat_IntegrationPurchaseTesterConfiguration.storekit @@ -18,6 +18,21 @@ "productID" : "lifetime", "referenceName" : "lifetime", "type" : "NonConsumable" + }, + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "67E2FE0B", + "localizations" : [ + { + "description" : "10 Coins", + "displayName" : "10 Coins", + "locale" : "en_US" + } + ], + "productID" : "consumable.10_coins", + "referenceName" : "10 coins", + "type" : "Consumable" } ], "settings" : { From 118df8a3c291e12af66f52faf354076decb036ab Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Fri, 7 Oct 2022 08:20:14 -0700 Subject: [PATCH 2/4] Added another test for multiple usrs --- .../StoreKitIntegrationTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index 842dbf6385..0864db3263 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -89,6 +89,23 @@ class StoreKit1IntegrationTests: BaseBackendIntegrationTests { ] } + func testCanPurchaseConsumableWithMultipleUsers() async throws { + func verifyPurchase(_ info: CustomerInfo) { + expect(info.nonSubscriptions).to(haveCount(1)) + expect(info.nonSubscriptions.onlyElement?.productIdentifier) == Self.consumable10Coins + } + + _ = try await Purchases.shared.logIn("user_1.\(UUID().uuidString)") + let info1 = try await self.purchaseConsumablePackage().customerInfo + verifyPurchase(info1) + + let user2 = try await Purchases.shared.logIn("user_1.\(UUID().uuidString)").customerInfo + expect(user2.nonSubscriptions).to(beEmpty()) + + let info2 = try await self.purchaseConsumablePackage().customerInfo + verifyPurchase(info2) + } + func testSubscriptionIsSandbox() async throws { let info = try await self.purchaseMonthlyOffering().customerInfo From 09d062c8e0aca07dc050c049947b2ee5a8ba5b1f Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Fri, 7 Oct 2022 11:22:48 -0700 Subject: [PATCH 3/4] Fixed --- Sources/Logging/Strings/PurchaseStrings.swift | 9 ++--- Sources/Misc/Deprecations.swift | 4 +- .../Purchases/PurchasesOrchestrator.swift | 36 +++++++++++++---- .../SK1StoreTransaction.swift | 3 +- .../SK2StoreTransaction.swift | 4 +- .../StoreTransaction.swift | 6 +-- .../StoreKitIntegrationTests.swift | 13 ++++++- .../Mocks/MockStoreKit1Wrapper.swift | 4 +- .../Purchases/PurchasesPurchasingTests.swift | 39 ++++++++++++++----- 9 files changed, 85 insertions(+), 33 deletions(-) diff --git a/Sources/Logging/Strings/PurchaseStrings.swift b/Sources/Logging/Strings/PurchaseStrings.swift index 7c6f61a791..8a74d21c89 100644 --- a/Sources/Logging/Strings/PurchaseStrings.swift +++ b/Sources/Logging/Strings/PurchaseStrings.swift @@ -20,7 +20,7 @@ enum PurchaseStrings { case cannot_purchase_product_appstore_configuration_error case entitlements_revoked_syncing_purchases(productIdentifiers: [String]) - case finishing_transaction(transaction: SKPaymentTransaction) + case finishing_transaction(StoreTransaction) case purchasing_with_observer_mode_and_finish_transactions_false_warning case paymentqueue_removedtransaction(transaction: SKPaymentTransaction) case paymentqueue_revoked_entitlements_for_product_identifiers(productIdentifiers: [String]) @@ -80,10 +80,9 @@ extension PurchaseStrings: CustomStringConvertible { return "Entitlements revoked for product " + "identifiers: \(productIdentifiers). \nsyncing purchases" - case .finishing_transaction(let transaction): - return "Finishing transaction \(transaction.payment.productIdentifier) " + - "\(transaction.transactionIdentifier ?? "") " + - "(\(transaction.original?.transactionIdentifier ?? ""))" + case let .finishing_transaction(transaction): + return "Finishing transaction '\(transaction.transactionIdentifier)' " + + "for product '\(transaction.productIdentifier)'" case .purchasing_with_observer_mode_and_finish_transactions_false_warning: return "Observer mode is active (finishTransactions is set to false) and " + diff --git a/Sources/Misc/Deprecations.swift b/Sources/Misc/Deprecations.swift index 4c9ab6473e..d1bfb7e77d 100644 --- a/Sources/Misc/Deprecations.swift +++ b/Sources/Misc/Deprecations.swift @@ -403,8 +403,8 @@ extension CustomerInfo { self.quantity = 1 } - func finish(_ wrapper: PaymentQueueWrapperType) { - // Nothing to do + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping () -> Void) { + completion() } } diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index 023ac556c0..912d74ed8b 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -734,7 +734,7 @@ private extension PurchasesOrchestrator { } } - self.finishTransactionIfNeeded(storeTransaction) + self.finishTransactionIfNeeded(storeTransaction, completion: {}) } func handleDeferredTransaction(_ transaction: SKPaymentTransaction) { @@ -899,23 +899,30 @@ private extension PurchasesOrchestrator { error: result.error) let completion = self.getAndRemovePurchaseCompletedCallback(forTransaction: transaction) + let error = result.error let finishable = error?.finishable ?? false switch result { case let .success(customerInfo): self.customerInfoManager.cache(customerInfo: customerInfo, appUserID: appUserID) - completion?(transaction, customerInfo, nil, false) - self.finishTransactionIfNeeded(transaction) + self.finishTransactionIfNeeded(transaction) { + completion?(transaction, customerInfo, nil, false) + } case let .failure(error): let purchasesError = error.asPublicError - completion?(transaction, nil, purchasesError, false) + @MainActor + func complete() { + completion?(transaction, nil, purchasesError, false) + } if finishable { - self.finishTransactionIfNeeded(transaction) + self.finishTransactionIfNeeded(transaction) { complete() } + } else { + complete() } } } @@ -1044,10 +1051,23 @@ private extension PurchasesOrchestrator { self.offeringsManager.invalidateAndReFetchCachedOfferingsIfAppropiate(appUserID: self.appUserID) } - func finishTransactionIfNeeded(_ transaction: StoreTransaction) { - if self.finishTransactions { - transaction.finish(self.paymentQueueWrapper.paymentQueueWrapperType) + func finishTransactionIfNeeded( + _ transaction: StoreTransaction, + completion: @escaping @Sendable @MainActor () -> Void + ) { + @Sendable + func complete() { + self.operationDispatcher.dispatchOnMainActor(completion) } + + guard self.finishTransactions else { + complete() + return + } + + Logger.purchase(Strings.purchase.finishing_transaction(transaction)) + + transaction.finish(self.paymentQueueWrapper.paymentQueueWrapperType, completion: complete) } } diff --git a/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift b/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift index 862a90de84..60ecbf4b77 100644 --- a/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift +++ b/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift @@ -31,8 +31,9 @@ internal struct SK1StoreTransaction: StoreTransactionType { let transactionIdentifier: String let quantity: Int - func finish(_ wrapper: PaymentQueueWrapperType) { + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping () -> Void) { wrapper.finishTransaction(self.underlyingSK1Transaction) + completion() } } diff --git a/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift b/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift index f97a2d2ba4..209a9927ea 100644 --- a/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift +++ b/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift @@ -32,8 +32,8 @@ internal struct SK2StoreTransaction: StoreTransactionType { let transactionIdentifier: String let quantity: Int - func finish(_ wrapper: PaymentQueueWrapperType) { - _ = Task { + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping () -> Void) { + Async.call(with: completion) { await self.underlyingSK2Transaction.finish() } } diff --git a/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift b/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift index 69b1c7dd53..62f65f41a3 100644 --- a/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift +++ b/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift @@ -42,8 +42,8 @@ public typealias SK2Transaction = StoreKit.Transaction @objc public var transactionIdentifier: String { self.transaction.transactionIdentifier } @objc public var quantity: Int { self.transaction.quantity } - func finish(_ wrapper: PaymentQueueWrapperType) { - self.transaction.finish(wrapper) + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) { + self.transaction.finish(wrapper, completion: completion) } // swiftlint:enable missing_docs @@ -87,7 +87,7 @@ internal protocol StoreTransactionType: Sendable { /// Indicates to the App Store that the app delivered the purchased content /// or enabled the service to finish the transaction. - func finish(_ wrapper: PaymentQueueWrapperType) + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) } diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index 0864db3263..0d99922776 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -75,6 +75,9 @@ class StoreKit1IntegrationTests: BaseBackendIntegrationTests { } func testCanPurchaseConsumableMultipleTimes() async throws { + // See https://revenuecats.atlassian.net/browse/TRIAGE-134 + try XCTSkipIf(Self.storeKit2Setting == .disabled, "This test is not currently passing on SK1") + let count = 2 for _ in 0.. PurchaseResultData { - let offering = try await XCTAsyncUnwrap(try await Purchases.shared.offerings().offering(identifier: "coins")) - let package = try XCTUnwrap(offering.package(identifier: "10.coins")) + let offering = try await XCTAsyncUnwrap( + try await Purchases.shared.offerings().offering(identifier: "coins"), + file: file, line: line + ) + let package = try XCTUnwrap( + offering.package(identifier: "10.coins"), + file: file, line: line + ) return try await Purchases.shared.purchase(package: package) } diff --git a/Tests/UnitTests/Mocks/MockStoreKit1Wrapper.swift b/Tests/UnitTests/Mocks/MockStoreKit1Wrapper.swift index b970d96aeb..69bffa1762 100644 --- a/Tests/UnitTests/Mocks/MockStoreKit1Wrapper.swift +++ b/Tests/UnitTests/Mocks/MockStoreKit1Wrapper.swift @@ -26,9 +26,11 @@ class MockStoreKit1Wrapper: StoreKit1Wrapper { } var finishCalled = false + var finishProductIdentifier: String? override func finishTransaction(_ transaction: SKPaymentTransaction) { - finishCalled = true + self.finishCalled = true + self.finishProductIdentifier = transaction.productIdentifier } weak var mockDelegate: StoreKit1WrapperDelegate? diff --git a/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift b/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift index 37e545be92..7551ae5d3d 100644 --- a/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift @@ -98,13 +98,23 @@ class PurchasesPurchasingTests: BasePurchasesTests { } func testFinishesTransactionsIfSentToBackendCorrectly() throws { - let product = StoreProduct(sk1Product: MockSK1Product(mockProductIdentifier: "com.product.id1")) - self.purchases.purchase(product: product) { (_, _, _, _) in } + var finished = true + + let productID = "com.product.id1" + let product = StoreProduct(sk1Product: MockSK1Product(mockProductIdentifier: productID)) + + self.purchases.purchase(product: product) { (_, _, _, _) in + // Transactions must be finished by the time the callback is invoked. + expect(self.storeKit1Wrapper.finishCalled) == true + expect(self.storeKit1Wrapper.finishProductIdentifier) == productID + + finished = true + } let transaction = MockTransaction() transaction.mockPayment = try XCTUnwrap(self.storeKit1Wrapper.payment) + transaction.mockState = .purchasing - transaction.mockState = SKPaymentTransactionState.purchasing self.storeKit1Wrapper.delegate?.storeKit1Wrapper(self.storeKit1Wrapper, updatedTransaction: transaction) self.backend.postReceiptResult = .success(try CustomerInfo(data: Self.emptyCustomerInfoData)) @@ -113,7 +123,7 @@ class PurchasesPurchasingTests: BasePurchasesTests { self.storeKit1Wrapper.delegate?.storeKit1Wrapper(self.storeKit1Wrapper, updatedTransaction: transaction) expect(self.backend.postReceiptDataCalled) == true - expect(self.storeKit1Wrapper.finishCalled).toEventually(beTrue()) + expect(finished).toEventually(beTrue()) } func testDoesntFinishTransactionsIfFinishingDisabled() throws { @@ -157,11 +167,22 @@ class PurchasesPurchasingTests: BasePurchasesTests { } func testAfterSendingFinishesFromBackendErrorIfAppropriate() throws { - let product = StoreProduct(sk1Product: MockSK1Product(mockProductIdentifier: "com.product.id1")) - self.purchases.purchase(product: product) { (_, _, _, _) in } + var finished = false + + let productID = "com.product.id1" + let product = StoreProduct(sk1Product: MockSK1Product(mockProductIdentifier: productID)) + + self.purchases.purchase(product: product) { (_, _, _, _) in + // Transactions must be finished by the time the callback is invoked. + expect(self.storeKit1Wrapper.finishCalled) == true + expect(self.storeKit1Wrapper.finishProductIdentifier) == productID + + finished = true + } let transaction = MockTransaction() transaction.mockPayment = try XCTUnwrap(self.storeKit1Wrapper.payment) + transaction.mockState = .purchased self.backend.postReceiptResult = .failure( .networkError(.errorResponse( @@ -170,11 +191,10 @@ class PurchasesPurchasingTests: BasePurchasesTests { )) ) - transaction.mockState = SKPaymentTransactionState.purchased self.storeKit1Wrapper.delegate?.storeKit1Wrapper(self.storeKit1Wrapper, updatedTransaction: transaction) expect(self.backend.postReceiptDataCalled) == true - expect(self.storeKit1Wrapper.finishCalled).toEventually(beTrue()) + expect(finished).toEventually(beTrue()) } func testNotifiesIfTransactionFailsFromBackend() throws { @@ -525,10 +545,11 @@ class PurchasesPurchasingTests: BasePurchasesTests { receivedError = error as NSError? secondCompletionCalled = true } + + self.performTransaction() } self.performTransaction() - self.performTransaction() } expect(secondCompletionCalled).toEventually(beTrue(), timeout: .seconds(10)) From 10023007ec2fc344d31a42dab7a17beac84a9312 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Fri, 7 Oct 2022 11:31:28 -0700 Subject: [PATCH 4/4] Added missing `@Sendable` --- Sources/Misc/Deprecations.swift | 2 +- .../Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift | 2 +- .../Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Misc/Deprecations.swift b/Sources/Misc/Deprecations.swift index d1bfb7e77d..8447d2108f 100644 --- a/Sources/Misc/Deprecations.swift +++ b/Sources/Misc/Deprecations.swift @@ -403,7 +403,7 @@ extension CustomerInfo { self.quantity = 1 } - func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping () -> Void) { + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) { completion() } diff --git a/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift b/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift index 60ecbf4b77..952bc99065 100644 --- a/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift +++ b/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift @@ -31,7 +31,7 @@ internal struct SK1StoreTransaction: StoreTransactionType { let transactionIdentifier: String let quantity: Int - func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping () -> Void) { + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) { wrapper.finishTransaction(self.underlyingSK1Transaction) completion() } diff --git a/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift b/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift index 209a9927ea..b3d847884f 100644 --- a/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift +++ b/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift @@ -32,7 +32,7 @@ internal struct SK2StoreTransaction: StoreTransactionType { let transactionIdentifier: String let quantity: Int - func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping () -> Void) { + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) { Async.call(with: completion) { await self.underlyingSK2Transaction.finish() }