From 81a867aa7a35cac2c20183d4494c64b6bec9405f Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Tue, 30 May 2023 13:22:03 -0700 Subject: [PATCH] Added `TransactionPoster` tests (#2557) Follow up to #2540. We could add more tests (which are a lot simpler than `PurchasesOrchestratorTests`) but this is just a start. --- RevenueCat.xcodeproj/project.pbxproj | 4 + .../Purchases/TransactionPoster.swift | 12 +- .../CustomerInfoManagerPostReceiptTests.swift | 8 +- .../Mocks/MockTransactionPoster.swift | 4 +- .../Purchases/TransactionPosterTests.swift | 168 ++++++++++++++++++ 5 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 Tests/UnitTests/Purchasing/Purchases/TransactionPosterTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index e4f946d45a..02baedb21f 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -202,6 +202,7 @@ 4F0201C42A13C85500091612 /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0201C32A13C85500091612 /* Assertions.swift */; }; 4F0BBA812A1D0524000E75AB /* DefaultDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0BBA802A1D0524000E75AB /* DefaultDecodable.swift */; }; 4F0BBAAC2A1D253D000E75AB /* OfflineCustomerInfoCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0BBAAB2A1D253D000E75AB /* OfflineCustomerInfoCreatorTests.swift */; }; + 4F0CE2BD2A215CE600561895 /* TransactionPosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0CE2BC2A215CE600561895 /* TransactionPosterTests.swift */; }; 4F2017D52A15587F0061F6EF /* OfflineStoreKitIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2017D42A15587F0061F6EF /* OfflineStoreKitIntegrationTests.swift */; }; 4F2018732A15797D0061F6EF /* TestLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57057FF728B0048900995F21 /* TestLogHandler.swift */; }; 4F3D56632A1E66A10070105A /* CustomerInfoManagerPostReceiptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3D56622A1E66A10070105A /* CustomerInfoManagerPostReceiptTests.swift */; }; @@ -886,6 +887,7 @@ 4F0201C32A13C85500091612 /* Assertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assertions.swift; sourceTree = ""; }; 4F0BBA802A1D0524000E75AB /* DefaultDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultDecodable.swift; sourceTree = ""; }; 4F0BBAAB2A1D253D000E75AB /* OfflineCustomerInfoCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineCustomerInfoCreatorTests.swift; sourceTree = ""; }; + 4F0CE2BC2A215CE600561895 /* TransactionPosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionPosterTests.swift; sourceTree = ""; }; 4F2017D42A15587F0061F6EF /* OfflineStoreKitIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineStoreKitIntegrationTests.swift; sourceTree = ""; }; 4F3D56622A1E66A10070105A /* CustomerInfoManagerPostReceiptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerInfoManagerPostReceiptTests.swift; sourceTree = ""; }; 4F54DF3E2A1D8C7500FD72BF /* MockStoreKit2TransactionFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreKit2TransactionFetcher.swift; sourceTree = ""; }; @@ -2163,6 +2165,7 @@ 57FDAA952846BDE2009A48F1 /* PurchasesTransactionHandlingTests.swift */, 57FDAA992846C2BD009A48F1 /* PurchasesDelegateTests.swift */, 57DBFA5C28AADA43002D18CA /* PurchasesLogInTests.swift */, + 4F0CE2BC2A215CE600561895 /* TransactionPosterTests.swift */, ); path = Purchases; sourceTree = ""; @@ -3219,6 +3222,7 @@ 4F8A58172A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift in Sources */, 2DDF41E824F6F61B005BC22D /* MockSKProductDiscount.swift in Sources */, 579189B728F4747700BF4963 /* EitherTests.swift in Sources */, + 4F0CE2BD2A215CE600561895 /* TransactionPosterTests.swift in Sources */, 5753EE00294B8C0C00CBAB54 /* AttributionFetcherTests.swift in Sources */, 57069A5E28E398E100B86355 /* AsyncExtensionsTests.swift in Sources */, 35272E2226D0048D00F22C3B /* HTTPClientTests.swift in Sources */, diff --git a/Sources/Purchasing/Purchases/TransactionPoster.swift b/Sources/Purchasing/Purchases/TransactionPoster.swift index d8c7015306..698c13509b 100644 --- a/Sources/Purchasing/Purchases/TransactionPoster.swift +++ b/Sources/Purchasing/Purchases/TransactionPoster.swift @@ -14,7 +14,7 @@ import Foundation /// Determines what triggered a purchase and whether it comes from a restore. -struct PurchaseSource { +struct PurchaseSource: Equatable { let isRestore: Bool let initiationSource: ProductRequestData.InitiationSource @@ -37,7 +37,7 @@ protocol TransactionPosterType: AnyObject, Sendable { /// Starts a `PostReceiptDataOperation` for the transaction. func handlePurchasedTransaction( - _ transaction: StoreTransaction, + _ transaction: StoreTransactionType, data: PurchasedTransactionData, completion: @escaping CustomerAPI.CustomerInfoResponseHandler ) @@ -77,7 +77,7 @@ final class TransactionPoster: TransactionPosterType { self.operationDispatcher = operationDispatcher } - func handlePurchasedTransaction(_ transaction: StoreTransaction, + func handlePurchasedTransaction(_ transaction: StoreTransactionType, data: PurchasedTransactionData, completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { self.receiptFetcher.receiptData( @@ -125,7 +125,7 @@ final class TransactionPoster: TransactionPosterType { private extension TransactionPoster { func fetchProductsAndPostReceipt( - transaction: StoreTransaction, + transaction: StoreTransactionType, data: PurchasedTransactionData, receiptData: Data, completion: @escaping CustomerAPI.CustomerInfoResponseHandler @@ -146,7 +146,7 @@ private extension TransactionPoster { } } - func handleReceiptPost(withTransaction transaction: StoreTransaction, + func handleReceiptPost(withTransaction transaction: StoreTransactionType, result: Result, subscriberAttributes: SubscriberAttribute.Dictionary?, completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { @@ -173,7 +173,7 @@ private extension TransactionPoster { } } - func postReceipt(transaction: StoreTransaction, + func postReceipt(transaction: StoreTransactionType, purchasedTransactionData: PurchasedTransactionData, receiptData: Data, product: StoreProduct?, diff --git a/Tests/UnitTests/Identity/CustomerInfoManagerPostReceiptTests.swift b/Tests/UnitTests/Identity/CustomerInfoManagerPostReceiptTests.swift index 848ccc236d..14db4ca81b 100644 --- a/Tests/UnitTests/Identity/CustomerInfoManagerPostReceiptTests.swift +++ b/Tests/UnitTests/Identity/CustomerInfoManagerPostReceiptTests.swift @@ -74,7 +74,7 @@ class CustomerInfoManagerPostReceiptTests: BaseCustomerInfoManagerTests { let parameters = try XCTUnwrap(self.mockTransactionPoster.invokedHandlePurchasedTransactionParameters.value) - expect(parameters.transaction) === transaction + expect(parameters.transaction as? StoreTransaction) === transaction expect(parameters.data.appUserID) == Self.userID expect(parameters.data.presentedOfferingID).to(beNil()) expect(parameters.data.unsyncedAttributes).to(beEmpty()) @@ -98,8 +98,10 @@ class CustomerInfoManagerPostReceiptTests: BaseCustomerInfoManagerTests { isAppBackgrounded: false) expect(self.mockBackend.invokedGetSubscriberData) == false expect(self.mockTransactionPoster.invokedHandlePurchasedTransaction.value) == true - expect(self.mockTransactionPoster.invokedHandlePurchasedTransactionParameters.value?.transaction) - === transaction + expect( + self.mockTransactionPoster.invokedHandlePurchasedTransactionParameters + .value?.transaction as? StoreTransaction + ) === transaction logger.verifyMessageWasLogged( Strings.customerInfo.posting_transaction_in_lieu_of_fetching_customerinfo(transaction), diff --git a/Tests/UnitTests/Mocks/MockTransactionPoster.swift b/Tests/UnitTests/Mocks/MockTransactionPoster.swift index 1e4c86cc51..442a29de69 100644 --- a/Tests/UnitTests/Mocks/MockTransactionPoster.swift +++ b/Tests/UnitTests/Mocks/MockTransactionPoster.swift @@ -23,11 +23,11 @@ final class MockTransactionPoster: TransactionPosterType { ) let invokedHandlePurchasedTransaction: Atomic = false let invokedHandlePurchasedTransactionCount: Atomic = .init(0) - let invokedHandlePurchasedTransactionParameters: Atomic<(transaction: StoreTransaction, + let invokedHandlePurchasedTransactionParameters: Atomic<(transaction: StoreTransactionType, data: PurchasedTransactionData)?> = nil func handlePurchasedTransaction( - _ transaction: StoreTransaction, + _ transaction: StoreTransactionType, data: PurchasedTransactionData, completion: @escaping CustomerAPI.CustomerInfoResponseHandler ) { diff --git a/Tests/UnitTests/Purchasing/Purchases/TransactionPosterTests.swift b/Tests/UnitTests/Purchasing/Purchases/TransactionPosterTests.swift new file mode 100644 index 0000000000..73495f0b1c --- /dev/null +++ b/Tests/UnitTests/Purchasing/Purchases/TransactionPosterTests.swift @@ -0,0 +1,168 @@ +// +// 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 +// +// TransactionPosterTests.swift +// +// Created by Nacho Soto on 5/26/23. + +import Nimble +import XCTest + +@testable import RevenueCat + +class TransactionPosterTests: TestCase { + + private var productsManager: MockProductsManager! + private var receiptFetcher: MockReceiptFetcher! + private var backend: MockBackend! + private var paymentQueueWrapper: MockPaymentQueueWrapper! + private var systemInfo: MockSystemInfo! + private var operationDispatcher: MockOperationDispatcher! + + private var poster: TransactionPoster! + + private var mockTransaction: MockStoreTransaction! + private static let mockCustomerInfo: CustomerInfo = .emptyInfo + + override func setUpWithError() throws { + try super.setUpWithError() + + self.setUp(observerMode: false) + self.mockTransaction = .init() + } + + func testHandlePurchasedTransactionWithMissingReceipt() throws { + self.receiptFetcher.shouldReturnReceipt = false + + let result = try self.handleTransaction( + .init( + appUserID: "user", + source: .init(isRestore: false, initiationSource: .queue) + ) + ) + expect(result).to(beFailure()) + expect(result.error) == BackendError.missingReceiptFile() + } + + func testHandlePurchasedTransaction() throws { + let product = MockSK1Product(mockProductIdentifier: "product") + let transactionData = PurchasedTransactionData( + appUserID: "user", + source: .init(isRestore: false, initiationSource: .queue) + ) + + self.receiptFetcher.shouldReturnReceipt = true + 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?.observerMode) == self.systemInfo.observerMode + expect(self.mockTransaction.finishInvoked) == true + } + + func testHandlePurchasedTransactionDoesNotFinishTransactionInObserverMode() throws { + self.setUp(observerMode: true) + + let product = MockSK1Product(mockProductIdentifier: "product") + let transactionData = PurchasedTransactionData( + appUserID: "user", + source: .init(isRestore: false, initiationSource: .queue) + ) + + self.receiptFetcher.shouldReturnReceipt = true + 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?.observerMode) == true + expect(self.mockTransaction.finishInvoked) == false + } + + func testFinishTransactionInObserverMode() throws { + let logger = TestLogHandler() + + waitUntil { completed in + self.poster.finishTransactionIfNeeded(self.mockTransaction) { + completed() + } + } + + logger.verifyMessageWasLogged(Strings.purchase.finishing_transaction(self.mockTransaction)) + } + + func testFinishTransactionDoesNotFinishInObserverMode() throws { + self.setUp(observerMode: true) + let logger = TestLogHandler() + + waitUntil { completed in + self.poster.finishTransactionIfNeeded(self.mockTransaction) { + completed() + } + } + + logger.verifyMessageWasNotLogged("Finished transaction") + } + +} + +// MARK: - + +private extension TransactionPosterTests { + + func setUp(observerMode: Bool) { + self.operationDispatcher = .init() + self.systemInfo = .init(finishTransactions: !observerMode) + self.productsManager = .init(systemInfo: self.systemInfo, requestTimeout: 0) + self.receiptFetcher = .init(requestFetcher: .init(operationDispatcher: self.operationDispatcher), + systemInfo: self.systemInfo) + self.backend = .init() + self.paymentQueueWrapper = .init() + + self.poster = .init( + productsManager: self.productsManager, + receiptFetcher: self.receiptFetcher, + backend: self.backend, + paymentQueueWrapper: .right(self.paymentQueueWrapper), + systemInfo: self.systemInfo, + operationDispatcher: self.operationDispatcher + ) + } + + func handleTransaction(_ data: PurchasedTransactionData) throws -> Result { + let result = waitUntilValue { completion in + self.poster.handlePurchasedTransaction(self.mockTransaction, data: data) { + completion($0) + } + } + + return try XCTUnwrap(result) + } + +} + +private func match(_ data: PurchasedTransactionData) -> Predicate { + return .init { + let other = try $0.evaluate() + let matches = (other?.appUserID == data.appUserID && + other?.presentedOfferingID == data.presentedOfferingID && + other?.source == data.source && + other?.unsyncedAttributes == data.unsyncedAttributes) + + return .init(bool: matches, message: .fail("PurchasedTransactionData do not match")) + } +}