From 607eaa99a7dc5a817c2117ef57aecfaca9aa448b Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Mon, 14 Nov 2022 12:35:32 -0800 Subject: [PATCH] `StoreKit 2`: don't finish transactions in observer mode ### Changes: - Greatly increased test coverage for `StoreKit2TransactionListener` - Extracted `AsyncSequence.extractValues` - Created `MockStoreTransaction` to be able to mock and verify `finish()` calls - Simplified `StoreKit2TransactionListener`, moved `finish` call to `PurchasesOrchestrator` - Follow up to #1965, also finish SK2 transactions from `StoreKit.Transaction.updates` --- RevenueCat.xcodeproj/project.pbxproj | 20 +++ Sources/Logging/Strings/PurchaseStrings.swift | 2 +- .../Purchases/PurchasesOrchestrator.swift | 13 +- .../StoreKit2TransactionListener.swift | 16 ++- .../StoreKitIntegrationTests.swift | 10 -- .../PurchasesOrchestratorTests.swift | 40 ++++-- .../StoreKit2TransactionListenerTests.swift | 130 +++++++++++++++++- ...StoreKit2TransactionListenerDelegate.swift | 30 ++++ .../Mocks/MockStoreTransaction.swift | 41 ++++++ .../TestHelpers/AsyncTestHelpers.swift | 26 ++++ 10 files changed, 294 insertions(+), 34 deletions(-) create mode 100644 Tests/UnitTests/Mocks/MockStoreKit2TransactionListenerDelegate.swift create mode 100644 Tests/UnitTests/Mocks/MockStoreTransaction.swift create mode 100644 Tests/UnitTests/TestHelpers/AsyncTestHelpers.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 2e0914da54..dd37bdd5af 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -249,6 +249,11 @@ 57554CC1282AE1E3009A7E58 /* TestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57554CC0282AE1E3009A7E58 /* TestCase.swift */; }; 57554CC2282AE1E3009A7E58 /* TestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57554CC0282AE1E3009A7E58 /* TestCase.swift */; }; 575A17AB2773A59300AA6F22 /* CurrentTestCaseTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575A17AA2773A59300AA6F22 /* CurrentTestCaseTracker.swift */; }; + 575A8EE12922C56300936709 /* AsyncTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575A8EE02922C56300936709 /* AsyncTestHelpers.swift */; }; + 575A8EE22922C56300936709 /* AsyncTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575A8EE02922C56300936709 /* AsyncTestHelpers.swift */; }; + 575A8EE32922C5E100936709 /* AsyncTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575A8EE02922C56300936709 /* AsyncTestHelpers.swift */; }; + 575A8EE52922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575A8EE42922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift */; }; + 575A8EE62922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575A8EE42922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift */; }; 5766AA3E283C750300FA6091 /* Operators+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5766AA3D283C750300FA6091 /* Operators+Extensions.swift */; }; 5766AA42283C768600FA6091 /* OperatorExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5766AA41283C768600FA6091 /* OperatorExtensionsTests.swift */; }; 5766AA56283D4C5400FA6091 /* IgnoreHashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5766AA55283D4C5400FA6091 /* IgnoreHashable.swift */; }; @@ -358,6 +363,8 @@ 57FDAABA284937A0009A48F1 /* SandboxEnvironmentDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FDAAB9284937A0009A48F1 /* SandboxEnvironmentDetector.swift */; }; 57FDAABE28493A29009A48F1 /* SandboxEnvironmentDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FDAABD28493A29009A48F1 /* SandboxEnvironmentDetectorTests.swift */; }; 57FDAAC028493C13009A48F1 /* MockSandboxEnvironmentDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FDAABF28493C13009A48F1 /* MockSandboxEnvironmentDetector.swift */; }; + 57FFD2512922DBED00A9A878 /* MockStoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FFD2502922DBED00A9A878 /* MockStoreTransaction.swift */; }; + 57FFD2522922DBED00A9A878 /* MockStoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FFD2502922DBED00A9A878 /* MockStoreTransaction.swift */; }; 6E38843A0CAFD551013D0A3F /* StoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECF627761D375C8431EB866 /* StoreProduct.swift */; }; 805B60C97993B311CEC93EAF /* ProductsFetcherSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3628C1F100BB3C1782860D24 /* ProductsFetcherSK2.swift */; }; 80E80EF226970E04008F245A /* ReceiptFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E80EF026970DC3008F245A /* ReceiptFetcher.swift */; }; @@ -770,6 +777,8 @@ 57554C87282AC293009A7E58 /* PurchaseOwnershipTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseOwnershipTypeTests.swift; sourceTree = ""; }; 57554CC0282AE1E3009A7E58 /* TestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestCase.swift; sourceTree = ""; }; 575A17AA2773A59300AA6F22 /* CurrentTestCaseTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentTestCaseTracker.swift; sourceTree = ""; }; + 575A8EE02922C56300936709 /* AsyncTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTestHelpers.swift; sourceTree = ""; }; + 575A8EE42922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreKit2TransactionListenerDelegate.swift; sourceTree = ""; }; 5766AA3D283C750300FA6091 /* Operators+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Operators+Extensions.swift"; sourceTree = ""; }; 5766AA41283C768600FA6091 /* OperatorExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorExtensionsTests.swift; sourceTree = ""; }; 5766AA55283D4C5400FA6091 /* IgnoreHashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IgnoreHashable.swift; sourceTree = ""; }; @@ -871,6 +880,7 @@ 57FDAAB9284937A0009A48F1 /* SandboxEnvironmentDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxEnvironmentDetector.swift; sourceTree = ""; }; 57FDAABD28493A29009A48F1 /* SandboxEnvironmentDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxEnvironmentDetectorTests.swift; sourceTree = ""; }; 57FDAABF28493C13009A48F1 /* MockSandboxEnvironmentDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSandboxEnvironmentDetector.swift; sourceTree = ""; }; + 57FFD2502922DBED00A9A878 /* MockStoreTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreTransaction.swift; sourceTree = ""; }; 80E80EF026970DC3008F245A /* ReceiptFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptFetcher.swift; sourceTree = ""; }; 84C3F1AC1D7E1E64341D3936 /* Pods_RevenueCat_PurchasesTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RevenueCat_PurchasesTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9A65DFDD258AD60A00DE00B0 /* LogIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogIntent.swift; sourceTree = ""; }; @@ -1423,6 +1433,8 @@ 37E357D16038F07915D7825D /* MockUserDefaults.swift */, 2DDF41E924F6F844005BC22D /* SKProductSubscriptionDurationExtensions.swift */, 5793397128E77A6E00C1232C /* MockPaymentQueue.swift */, + 575A8EE42922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift */, + 57FFD2502922DBED00A9A878 /* MockStoreTransaction.swift */, ); path = Mocks; sourceTree = ""; @@ -1709,6 +1721,7 @@ 57057FF728B0048900995F21 /* TestLogHandler.swift */, 5705800228B0085200995F21 /* Box.swift */, 57E4A52028BD8F610095C793 /* ErrorMatcher.swift */, + 575A8EE02922C56300936709 /* AsyncTestHelpers.swift */, ); path = TestHelpers; sourceTree = ""; @@ -2325,6 +2338,7 @@ 3543914426F911F300E669DF /* MockCustomerInfoManager.swift in Sources */, 5738F46E278CAC520096D623 /* StoreTransactionTests.swift in Sources */, 3543914226F911F300E669DF /* MockSK1Product.swift in Sources */, + 57FFD2522922DBED00A9A878 /* MockStoreTransaction.swift in Sources */, 3543913826F90FE100E669DF /* MockIntroEligibilityCalculator.swift in Sources */, 3543914126F911CC00E669DF /* MockDeviceCache.swift in Sources */, 3543913C26F9101600E669DF /* MockOperationDispatcher.swift in Sources */, @@ -2350,6 +2364,7 @@ F5847431278D00C1001B1CE6 /* MockDNSChecker.swift in Sources */, B3CAFF1F285CEAAA0048A994 /* MockOfferingsAPI.swift in Sources */, F55FFA632763F60700995146 /* TransactionsManagerTests.swift in Sources */, + 575A8EE62922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift in Sources */, 2D222BAB27FB7008003D5F37 /* LocalReceiptParserStoreKitTests.swift in Sources */, B31C8BEF285BDD76001017B7 /* MockIdentityAPI.swift in Sources */, 571E7AD4279F2D0C003B3606 /* StoreKitTestHelpers.swift in Sources */, @@ -2375,6 +2390,7 @@ 576C8AB927D2996C0058FA6E /* CurrentTestCaseTracker.swift in Sources */, B3BE0264275942D500915B4C /* AvailabilityChecks.swift in Sources */, 2D90F8B326FD2082009B9142 /* MockProductsManager.swift in Sources */, + 575A8EE32922C5E100936709 /* AsyncTestHelpers.swift in Sources */, 57DE80BF2807705F008D6C6F /* XCTestCase+Extensions.swift in Sources */, 2D90F8CA26FD257A009B9142 /* MockStoreKit2TransactionListener.swift in Sources */, 35B745A82711001A00458D46 /* MockManageSubscriptionsHelper.swift in Sources */, @@ -2647,6 +2663,7 @@ 5774F9C12805EA3000997128 /* BaseHTTPResponseTest.swift in Sources */, 351B51B526D450E800BD2BD7 /* ProductsFetcherSK1Tests.swift in Sources */, 2DDF41CC24F6F4C3005BC22D /* AppleReceiptBuilderTests.swift in Sources */, + 575A8EE52922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift in Sources */, 2DDF41CD24F6F4C3005BC22D /* ASN1ContainerBuilderTests.swift in Sources */, 573A10D52800A7C800F976E5 /* SKErrorTests.swift in Sources */, B31C8BEC285BD58F001017B7 /* MockIdentityAPI.swift in Sources */, @@ -2673,6 +2690,7 @@ 351B51BA26D450E800BD2BD7 /* ProductRequestDataExtensions.swift in Sources */, 574A2F3F282D75E300150D40 /* OfferingsDecodingTests.swift in Sources */, 35E840CE2710E2EB00899AE2 /* MockManageSubscriptionsHelper.swift in Sources */, + 57FFD2512922DBED00A9A878 /* MockStoreTransaction.swift in Sources */, F591492826B9956C00D32E58 /* MockTransaction.swift in Sources */, 5796A39427D6BD6900653165 /* BackendGetOfferingsTests.swift in Sources */, 5705800328B0085200995F21 /* Box.swift in Sources */, @@ -2750,6 +2768,7 @@ F5847430278D00C0001B1CE6 /* MockDNSChecker.swift in Sources */, 5722482727C2BD3200C524A7 /* LockTests.swift in Sources */, 351B514526D449E600BD2BD7 /* MockAttributionTypeFactory.swift in Sources */, + 575A8EE12922C56300936709 /* AsyncTestHelpers.swift in Sources */, 572247F727BF1ADF00C524A7 /* ArrayExtensionsTests.swift in Sources */, F55FFA5A27634C3F00995146 /* MockTransactionsManager.swift in Sources */, F575858F26C0893600C12B97 /* MockOfferingsManager.swift in Sources */, @@ -2770,6 +2789,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 575A8EE22922C56300936709 /* AsyncTestHelpers.swift in Sources */, 2DA85A8B26DEA7DD00F1136D /* MockProductsRequestFactory.swift in Sources */, 2D3BFAD326DEA47100370B11 /* MockSKProductDiscount.swift in Sources */, 57DE80BE28077010008D6C6F /* XCTestCase+Extensions.swift in Sources */, diff --git a/Sources/Logging/Strings/PurchaseStrings.swift b/Sources/Logging/Strings/PurchaseStrings.swift index 8a74d21c89..ae1ccd5a1b 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(StoreTransaction) + case finishing_transaction(StoreTransactionType) case purchasing_with_observer_mode_and_finish_transactions_false_warning case paymentqueue_removedtransaction(transaction: SKPaymentTransaction) case paymentqueue_revoked_entitlements_for_product_identifiers(productIdentifiers: [String]) diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index 6918a08049..7301db02e9 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -769,7 +769,16 @@ private extension PurchasesOrchestrator { @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) extension PurchasesOrchestrator: StoreKit2TransactionListenerDelegate { - func transactionsUpdated() async throws { + func storeKit2TransactionListener( + _ listener: StoreKit2TransactionListener, + updatedTransaction transaction: StoreTransactionType + ) async throws { + await Async.call { completion in + self.finishTransactionIfNeeded(transaction) { @MainActor in + completion(()) + } + } + // Need to restore if using observer mode (which is inverse of finishTransactions) let isRestore = !self.systemInfo.finishTransactions @@ -1059,7 +1068,7 @@ private extension PurchasesOrchestrator { } func finishTransactionIfNeeded( - _ transaction: StoreTransaction, + _ transaction: StoreTransactionType, completion: @escaping @Sendable @MainActor () -> Void ) { @Sendable diff --git a/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift b/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift index 38ea78c92b..eb1318bd07 100644 --- a/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift +++ b/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift @@ -17,7 +17,10 @@ import StoreKit @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) protocol StoreKit2TransactionListenerDelegate: AnyObject { - func transactionsUpdated() async throws + func storeKit2TransactionListener( + _ listener: StoreKit2TransactionListener, + updatedTransaction transaction: StoreTransactionType + ) async throws } @@ -98,13 +101,12 @@ private extension StoreKit2TransactionListener { error: verificationError ) - case .verified(let verifiedTransaction): - if fromTransactionUpdate { // Otherwise transaction will be finished by `PurchasesOrchestrator` - await verifiedTransaction.finish() - } - + case let .verified(verifiedTransaction): if fromTransactionUpdate, let delegate = self.delegate { - _ = try await delegate.transactionsUpdated() + try await delegate.storeKit2TransactionListener( + self, + updatedTransaction: StoreTransaction(sk2Transaction: verifiedTransaction) + ) } return verifiedTransaction diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index 2304e89fef..089a489f7f 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -636,13 +636,3 @@ private extension StoreKit1IntegrationTests { } } - -private extension AsyncSequence { - - func extractValues() async rethrows -> [Element] { - return try await self.reduce(into: [Element]()) { - $0 += [$1] - } - } - -} diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift index 5bda42785b..e673123404 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift @@ -510,29 +510,45 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { func testStoreKit2TransactionListenerDelegate() async throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() - customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo - backend.stubbedPostReceiptResult = .success(mockCustomerInfo) + self.setUpStoreKit2Listener() - try await self.orchestrator.transactionsUpdated() + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) - expect(self.backend.invokedPostReceiptData).to(beTrue()) - expect(self.backend.invokedPostReceiptDataParameters?.isRestore).to(beFalse()) + let transaction = MockStoreTransaction() + + try await self.orchestrator.storeKit2TransactionListener( + self.mockStoreKit2TransactionListener!, + updatedTransaction: transaction + ) + + expect(transaction.finishInvoked) == true + expect(self.backend.invokedPostReceiptData) == true + expect(self.backend.invokedPostReceiptDataParameters?.isRestore) == false } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func testStoreKit2TransactionListenerDelegateWithObserverMode() async throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() - try setUpSystemInfo(finishTransactions: false) - setUpOrchestrator() + try self.setUpSystemInfo(finishTransactions: false, storeKit2Setting: .enabledForCompatibleDevices) - backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo + self.setUpOrchestrator() + self.setUpStoreKit2Listener() + + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo - try await self.orchestrator.transactionsUpdated() + let transaction = MockStoreTransaction() + + try await self.orchestrator.storeKit2TransactionListener( + self.mockStoreKit2TransactionListener!, + updatedTransaction: transaction + ) - expect(self.backend.invokedPostReceiptData).to(beTrue()) - expect(self.backend.invokedPostReceiptDataParameters?.isRestore).to(beTrue()) + expect(transaction.finishInvoked) == false + expect(self.backend.invokedPostReceiptData) == true + expect(self.backend.invokedPostReceiptDataParameters?.isRestore) == true } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) diff --git a/Tests/StoreKitUnitTests/StoreKit2TransactionListenerTests.swift b/Tests/StoreKitUnitTests/StoreKit2TransactionListenerTests.swift index 1eed9ff143..e9cdb23d12 100644 --- a/Tests/StoreKitUnitTests/StoreKit2TransactionListenerTests.swift +++ b/Tests/StoreKitUnitTests/StoreKit2TransactionListenerTests.swift @@ -20,11 +20,13 @@ import XCTest class StoreKit2TransactionListenerTests: StoreKitConfigTestCase { private var listener: StoreKit2TransactionListener! = nil + private var delegate: MockStoreKit2TransactionListenerDelegate! = nil override func setUp() { super.setUp() - self.listener = .init(delegate: nil) + self.delegate = .init() + self.listener = .init(delegate: self.delegate) } func testStopsListeningToTransactions() throws { @@ -49,7 +51,7 @@ class StoreKit2TransactionListenerTests: StoreKitConfigTestCase { func testVerifiedTransactionReturnsOriginalTransaction() async throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() - let fakeTransaction = try await createTransactionWithPurchase() + let fakeTransaction = try await self.createTransactionWithPurchase() let (isCancelled, transaction) = try await self.listener.handle( purchaseResult: .success(.verified(fakeTransaction)) @@ -96,4 +98,128 @@ class StoreKit2TransactionListenerTests: StoreKitConfigTestCase { } } + func testPurchasingDoesNotFinishTransaction() async throws { + self.listener.listenForTransactions() + + await self.verifyNoUnfinishedTransactions() + + let (_, _, purchasedTransaction) = try await self.purchase() + expect(purchasedTransaction.ownershipType) == .purchased + + try await self.verifyUnfinishedTransaction(withId: purchasedTransaction.id) + } + + func testPurchasingDoesNotNotifyDelegate() async throws { + self.listener.listenForTransactions() + + _ = try await self.fetchSk2Product().purchase() + + expect(self.delegate.invokedTransactionUpdated) == false + } + + func testHandlePurchaseResultDoesNotFinishTransaction() async throws { + self.listener.listenForTransactions() + + await self.verifyNoUnfinishedTransactions() + + let (purchaseResult, _, purchasedTransaction) = try await self.purchase() + + let sk2Transaction = try await self.listener.handle(purchaseResult: purchaseResult) + expect(sk2Transaction.transaction) == purchasedTransaction + expect(sk2Transaction.userCancelled) == false + + try await self.verifyUnfinishedTransaction(withId: purchasedTransaction.id) + } + + func testHandlePurchaseResultDoesNotNotifyDelegate() async throws { + self.listener.listenForTransactions() + + let result = try await self.purchase().result + _ = try await self.listener.handle(purchaseResult: result) + + expect(self.delegate.invokedTransactionUpdated) == false + } + + func testHandleUnverifiedPurchase() async throws { + let (_, _, transaction) = try await self.purchase() + + let verificationError: VerificationResult.VerificationError = .invalidSignature + + do { + _ = try await self.listener.handle( + purchaseResult: .success(.unverified(transaction, verificationError)) + ) + fail("Expected error") + } catch { + expect(error).to(matchError(ErrorCode.storeProblemError)) + + let underlyingError = try XCTUnwrap((error as NSError).userInfo[NSUnderlyingErrorKey] as? NSError) + expect(underlyingError).to(matchError(verificationError)) + } + } + + func testHandlePurchaseResultWithCancelledPurchase() async throws { + let result = try await self.listener.handle(purchaseResult: .userCancelled) + expect(result.userCancelled) == true + expect(result.transaction).to(beNil()) + } + + func testHandlePurchaseResultWithDeferredPurchase() async throws { + do { + _ = try await self.listener.handle(purchaseResult: .pending) + fail("Expected error") + } catch { + expect(error).to(matchError(ErrorCode.paymentPendingError)) + } + } + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +private extension StoreKit2TransactionListenerTests { + + private enum Error: Swift.Error { + case invalidResult(Product.PurchaseResult) + case invalidTransactions([VerificationResult]) + } + + func verifyNoUnfinishedTransactions(line: UInt = #line) async { + let unfinished = await StoreKit.Transaction.unfinished.extractValues() + expect(line: line, unfinished).to(beEmpty()) + } + + // swiftlint:disable:next large_tuple + func purchase() async throws -> ( + result: Product.PurchaseResult, + verificationResult: VerificationResult, + transaction: Transaction + ) { + let result = try await self.fetchSk2Product().purchase() + + guard case let .success(verificationResult) = result, + case let .verified(transaction) = verificationResult + else { + throw Error.invalidResult(result) + } + + return (result, verificationResult, transaction) + } + + func verifyUnfinishedTransaction( + withId identifier: Transaction.ID, + line: UInt = #line + ) async throws { + let unfinishedTransactions = await StoreKit.Transaction.unfinished.extractValues() + + expect(line: line, unfinishedTransactions).to(haveCount(1)) + + guard let transaction = unfinishedTransactions.onlyElement, + case let .verified(verified) = transaction else { + throw Error.invalidTransactions(unfinishedTransactions) + } + + expect(line: line, verified.id) == identifier + + } + } diff --git a/Tests/UnitTests/Mocks/MockStoreKit2TransactionListenerDelegate.swift b/Tests/UnitTests/Mocks/MockStoreKit2TransactionListenerDelegate.swift new file mode 100644 index 0000000000..764cdcc400 --- /dev/null +++ b/Tests/UnitTests/Mocks/MockStoreKit2TransactionListenerDelegate.swift @@ -0,0 +1,30 @@ +// +// 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 +// +// MockStoreKit2TransactionListenerDelegate.swift +// +// Created by Nacho Soto on 11/14/22. + +@testable import RevenueCat + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +final class MockStoreKit2TransactionListenerDelegate: StoreKit2TransactionListenerDelegate { + + var invokedTransactionUpdated = false + var updatedTransactions: [StoreTransactionType] = [] + + func storeKit2TransactionListener( + _ listener: StoreKit2TransactionListener, + updatedTransaction transaction: StoreTransactionType + ) async throws { + self.invokedTransactionUpdated = true + self.updatedTransactions.append(transaction) + } + +} diff --git a/Tests/UnitTests/Mocks/MockStoreTransaction.swift b/Tests/UnitTests/Mocks/MockStoreTransaction.swift new file mode 100644 index 0000000000..b5cff57f0a --- /dev/null +++ b/Tests/UnitTests/Mocks/MockStoreTransaction.swift @@ -0,0 +1,41 @@ +// +// 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 +// +// MockStoreTransaction.swift +// +// Created by Nacho Soto on 11/14/22. + +@testable import RevenueCat +import StoreKit + +final class MockStoreTransaction: StoreTransactionType { + + let productIdentifier: String + let purchaseDate: Date + let transactionIdentifier: String + let quantity: Int + + init() { + self.productIdentifier = UUID().uuidString + self.purchaseDate = Date() + self.transactionIdentifier = UUID().uuidString + self.quantity = 1 + } + + private let _finishInvoked: Atomic = false + + var finishInvoked: Bool { return self._finishInvoked.value } + + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping () -> Void) { + self._finishInvoked.value = true + + completion() + } + +} diff --git a/Tests/UnitTests/TestHelpers/AsyncTestHelpers.swift b/Tests/UnitTests/TestHelpers/AsyncTestHelpers.swift new file mode 100644 index 0000000000..a65065cd7b --- /dev/null +++ b/Tests/UnitTests/TestHelpers/AsyncTestHelpers.swift @@ -0,0 +1,26 @@ +// +// 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 +// +// AsyncTestHelpers.swift +// +// Created by Nacho Soto on 11/14/22. + +import Foundation + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +internal extension AsyncSequence { + + /// Returns the elements of the asynchronous sequence. + func extractValues() async rethrows -> [Element] { + return try await self.reduce(into: []) { + $0.append($1) + } + } + +}