From f0b484f66a151ac9fb6ee0bebf0c272cd34a2978 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Thu, 7 Sep 2023 16:45:28 -0700 Subject: [PATCH] `Paywalls`: events unit and integration tests ### Changes: - Added `PurchasesOrchestrator` tests for paywall data sent through post receipt - Fixed and tested state issue with cached paywall data and failed purchases - Added `Integration Tests` for tracking and flushing events - Configured `Integration Tests` with a custom documents directory to ensure it's empty on every test invocation - Changed deployment target on `Integration Tests` to iOS 16 to simplify code - Setting `Purchases.logLevel` before configuring purchases on `Integration Tests` - Added assertion to ensure `FileHandler` never runs on the main thread --- RevenueCat.xcodeproj/project.pbxproj | 8 +- Sources/Diagnostics/FileHandler.swift | 6 + Sources/Logging/Strings/PaywallsStrings.swift | 4 +- Sources/Networking/InternalAPI.swift | 7 +- .../Paywalls/Events/PaywallEventStore.swift | 12 +- .../Events/PaywallEventsManager.swift | 25 ++-- Sources/Purchasing/Purchases/Purchases.swift | 12 +- .../Purchases/PurchasesOrchestrator.swift | 98 +++++++++------ .../BaseBackendIntegrationTests.swift | 12 +- .../PaywallEventsIntegrationTests.swift | 97 +++++++++++++-- .../PurchasesOrchestratorTests.swift | 113 ++++++++++++++++++ .../Mocks/MockPaywallEventsManager.swift | 2 +- .../Events/PaywallEventStoreTests.swift | 2 +- .../Events/PaywallEventsManagerTests.swift | 59 ++++++--- 14 files changed, 367 insertions(+), 90 deletions(-) diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 84e4226f59..a8e002f6be 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -4183,7 +4183,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = Tests/BackendIntegrationTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4211,7 +4211,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = Tests/BackendIntegrationTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4477,7 +4477,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = Tests/BackendIntegrationTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4505,7 +4505,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = Tests/BackendIntegrationTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Sources/Diagnostics/FileHandler.swift b/Sources/Diagnostics/FileHandler.swift index f2726e23ec..1d0b9f9c38 100644 --- a/Sources/Diagnostics/FileHandler.swift +++ b/Sources/Diagnostics/FileHandler.swift @@ -56,6 +56,8 @@ actor FileHandler: FileHandlerType { /// - Note: this loads the entire file in memory /// For newer versions, consider using `readLines` instead. func readFile() throws -> Data { + RCTestAssertNotMainThread() + try self.moveToBeginningOfFile() return self.fileHandle.availableData @@ -64,6 +66,8 @@ actor FileHandler: FileHandlerType { /// Returns an async sequence for every line in the file @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func readLines() throws -> AsyncLineSequence { + RCTestAssertNotMainThread() + try self.moveToBeginningOfFile() return self.fileHandle.bytes.lines @@ -71,6 +75,8 @@ actor FileHandler: FileHandlerType { /// Adds a line at the end of the file func append(line: String) { + RCTestAssertNotMainThread() + self.fileHandle.seekToEndOfFile() self.fileHandle.write(line.asData) self.fileHandle.write(Self.lineBreakData) diff --git a/Sources/Logging/Strings/PaywallsStrings.swift b/Sources/Logging/Strings/PaywallsStrings.swift index e362715e9a..7303ab83db 100644 --- a/Sources/Logging/Strings/PaywallsStrings.swift +++ b/Sources/Logging/Strings/PaywallsStrings.swift @@ -32,7 +32,7 @@ enum PaywallsStrings { case event_flush_already_in_progress case event_flush_with_empty_store case event_flush_starting(count: Int) - case event_flush_failed(BackendError) + case event_flush_failed(Error) } @@ -74,7 +74,7 @@ extension PaywallsStrings: LogMessage { return "Paywall event flush: posting \(count) events." case let .event_flush_failed(error): - return "Paywall event flushing failed, will retry. Error: \(error.localizedDescription)" + return "Paywall event flushing failed, will retry. Error: \((error as NSError).localizedDescription)" } } diff --git a/Sources/Networking/InternalAPI.swift b/Sources/Networking/InternalAPI.swift index 2d7380e64b..66fc54c0a6 100644 --- a/Sources/Networking/InternalAPI.swift +++ b/Sources/Networking/InternalAPI.swift @@ -58,11 +58,14 @@ class InternalAPI { extension InternalAPI { + /// - Throws: `BackendError` @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) - func postPaywallEvents(events: [PaywallStoredEvent]) async -> BackendError? { - return await Async.call { completion in + func postPaywallEvents(events: [PaywallStoredEvent]) async throws { + let error = await Async.call { completion in self.postPaywallEvents(events: events, completion: completion) } + + if let error { throw error } } } diff --git a/Sources/Paywalls/Events/PaywallEventStore.swift b/Sources/Paywalls/Events/PaywallEventStore.swift index 5d0ad59636..19d71e06af 100644 --- a/Sources/Paywalls/Events/PaywallEventStore.swift +++ b/Sources/Paywalls/Events/PaywallEventStore.swift @@ -85,11 +85,14 @@ internal actor PaywallEventStore: PaywallEventStoreType { @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) extension PaywallEventStore { - static func createDefault() throws -> PaywallEventStore { - let url = try Self.documentsDirectory + static func createDefault(documentsDirectory: URL?) throws -> PaywallEventStore { + let documentsDirectory = try documentsDirectory ?? Self.documentsDirectory + let url = documentsDirectory .appendingPathComponent("revenuecat") .appendingPathComponent("paywall_event_store") + Logger.verbose(PaywallEventStoreStrings.initializing(url)) + return try .init(handler: FileHandler(url)) } @@ -116,6 +119,8 @@ extension PaywallEventStore { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private enum PaywallEventStoreStrings { + case initializing(URL) + case storing_event(PaywallEvent) case error_storing_event(Error) @@ -131,6 +136,9 @@ extension PaywallEventStoreStrings: LogMessage { var description: String { switch self { + case let .initializing(directory): + return "Initializing PaywallEventStore: \(directory.absoluteString)" + case let .storing_event(event): return "Storing event: \(event.debugDescription)" diff --git a/Sources/Paywalls/Events/PaywallEventsManager.swift b/Sources/Paywalls/Events/PaywallEventsManager.swift index e17404c6a3..0d4c6559ef 100644 --- a/Sources/Paywalls/Events/PaywallEventsManager.swift +++ b/Sources/Paywalls/Events/PaywallEventsManager.swift @@ -18,8 +18,10 @@ protocol PaywallEventsManagerType { @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func track(paywallEvent: PaywallEvent) async + /// - Throws: if posting events fails + /// - Returns: the number of events posted @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func flushEvents(count: Int) async + func flushEvents(count: Int) async throws -> Int } @@ -46,10 +48,10 @@ final class PaywallEventsManager: PaywallEventsManagerType { await self.store.store(.init(event: paywallEvent, userID: self.userProvider.currentAppUserID)) } - func flushEvents(count: Int) async { + func flushEvents(count: Int) async throws -> Int { guard !self.flushInProgress else { Logger.debug(Strings.paywalls.event_flush_already_in_progress) - return + return 0 } self.flushInProgress = true defer { self.flushInProgress = false } @@ -58,21 +60,26 @@ final class PaywallEventsManager: PaywallEventsManagerType { guard !events.isEmpty else { Logger.verbose(Strings.paywalls.event_flush_with_empty_store) - return + return 0 } Logger.verbose(Strings.paywalls.event_flush_starting(count: events.count)) - let error = await self.internalAPI.postPaywallEvents(events: events) + do { + try await self.internalAPI.postPaywallEvents(events: events) - if let error { + await self.store.clear(count) + + return events.count + } catch { Logger.error(Strings.paywalls.event_flush_failed(error)) - if error.successfullySynced { + if let backendError = error as? BackendError, + backendError.successfullySynced { await self.store.clear(count) } - } else { - await self.store.clear(count) + + throw error } } diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index e13069089f..d432b3597e 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -260,6 +260,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void convenience init(apiKey: String, appUserID: String?, userDefaults: UserDefaults? = nil, + documentsDirectory: URL? = nil, observerMode: Bool = false, platformInfo: PlatformInfo? = Purchases.platformInfo, responseVerificationMode: Signing.ResponseVerificationMode, @@ -359,7 +360,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void paywallEventsManager = PaywallEventsManager( internalAPI: backend.internalAPI, userProvider: identityManager, - store: try PaywallEventStore.createDefault() + store: try PaywallEventStore.createDefault(documentsDirectory: documentsDirectory) ) } else { Logger.verbose(Strings.paywalls.event_manager_not_initialized_not_available) @@ -1276,6 +1277,7 @@ public extension Purchases { appUserID: String?, observerMode: Bool, userDefaults: UserDefaults?, + documentsDirectory: URL? = nil, platformInfo: PlatformInfo?, responseVerificationMode: Signing.ResponseVerificationMode, storeKit2Setting: StoreKit2Setting, @@ -1287,6 +1289,7 @@ public extension Purchases { .init(apiKey: apiKey, appUserID: appUserID, userDefaults: userDefaults, + documentsDirectory: documentsDirectory, observerMode: observerMode, platformInfo: platformInfo, responseVerificationMode: responseVerificationMode, @@ -1541,6 +1544,13 @@ internal extension Purchases { self.offeringsManager.invalidateCachedOfferings(appUserID: self.appUserID) } + /// - Throws: if posting events fails + /// - Returns: the number of events posted + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func flushPaywallEvents(count: Int) async throws -> Int { + return try await self.paywallEventsManager?.flushEvents(count: count) ?? 0 + } + } #endif diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index aeb6e2bb61..ede6cdd639 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -877,27 +877,29 @@ extension PurchasesOrchestrator: StoreKit2TransactionListenerDelegate { let storefront = await self.storefront(from: transaction) let subscriberAttributes = self.unsyncedAttributes let adServicesToken = self.attribution.unsyncedAdServicesToken + let transactionData: PurchasedTransactionData = .init( + appUserID: self.appUserID, + presentedOfferingID: nil, + unsyncedAttributes: subscriberAttributes, + aadAttributionToken: adServicesToken, + storefront: storefront, + source: .init( + isRestore: self.allowSharingAppStoreAccount, + initiationSource: .queue + ) + ) let result: Result = await Async.call { completed in self.transactionPoster.handlePurchasedTransaction( StoreTransaction.from(transaction: transaction), - data: .init( - appUserID: self.appUserID, - presentedOfferingID: nil, - unsyncedAttributes: subscriberAttributes, - aadAttributionToken: adServicesToken, - storefront: storefront, - source: .init( - isRestore: self.allowSharingAppStoreAccount, - initiationSource: .queue - ) - ) + data: transactionData ) { result in completed(result) } } self.handlePostReceiptResult(result, + transactionData: transactionData, subscriberAttributes: subscriberAttributes, adServicesToken: adServicesToken) @@ -989,6 +991,7 @@ private extension PurchasesOrchestrator { } } + // swiftlint:disable:next function_body_length func syncPurchases(receiptRefreshPolicy: ReceiptRefreshPolicy, isRestore: Bool, initiationSource: ProductRequestData.InitiationSource, @@ -1035,17 +1038,20 @@ private extension PurchasesOrchestrator { } self.createProductRequestData(with: receiptData) { productRequestData in + let transactionData: PurchasedTransactionData = .init( + appUserID: currentAppUserID, + presentedOfferingID: nil, + unsyncedAttributes: unsyncedAttributes, + storefront: productRequestData?.storefront, + source: .init(isRestore: isRestore, initiationSource: initiationSource) + ) + self.backend.post(receiptData: receiptData, productData: productRequestData, - transactionData: .init( - appUserID: currentAppUserID, - presentedOfferingID: nil, - unsyncedAttributes: unsyncedAttributes, - storefront: productRequestData?.storefront, - source: .init(isRestore: isRestore, initiationSource: initiationSource) - ), + transactionData: transactionData, observerMode: self.observerMode) { result in self.handleReceiptPost(result: result, + transactionData: transactionData, subscriberAttributes: unsyncedAttributes, adServicesToken: adServicesToken, completion: completion) @@ -1056,11 +1062,13 @@ private extension PurchasesOrchestrator { } func handleReceiptPost(result: Result, + transactionData: PurchasedTransactionData, subscriberAttributes: SubscriberAttribute.Dictionary, adServicesToken: String?, completion: (@Sendable (Result) -> Void)?) { self.handlePostReceiptResult( result, + transactionData: transactionData, subscriberAttributes: subscriberAttributes, adServicesToken: adServicesToken ) @@ -1073,10 +1081,18 @@ private extension PurchasesOrchestrator { } func handlePostReceiptResult(_ result: Result, + transactionData: PurchasedTransactionData, subscriberAttributes: SubscriberAttribute.Dictionary, adServicesToken: String?) { - if let customerInfo = result.value { + switch result { + case let .success(customerInfo): self.customerInfoManager.cache(customerInfo: customerInfo, appUserID: self.appUserID) + + case .failure: + // Cache paywall again in case purchase is retried. + if let paywall = transactionData.presentedPaywall { + self.cachePresentedPaywall(paywall) + } } self.markSyncedIfNeeded(subscriberAttributes: subscriberAttributes, @@ -1091,21 +1107,23 @@ private extension PurchasesOrchestrator { let paywall = self.getAndRemovePresentedPaywall() let unsyncedAttributes = self.unsyncedAttributes let adServicesToken = self.attribution.unsyncedAdServicesToken + let transactionData: PurchasedTransactionData = .init( + appUserID: self.appUserID, + presentedOfferingID: offeringID, + presentedPaywall: paywall, + unsyncedAttributes: unsyncedAttributes, + aadAttributionToken: adServicesToken, + storefront: storefront, + source: self.purchaseSource(for: purchasedTransaction.productIdentifier, + restored: restored) + ) self.transactionPoster.handlePurchasedTransaction( purchasedTransaction, - data: .init( - appUserID: self.appUserID, - presentedOfferingID: offeringID, - presentedPaywall: paywall, - unsyncedAttributes: unsyncedAttributes, - aadAttributionToken: adServicesToken, - storefront: storefront, - source: self.purchaseSource(for: purchasedTransaction.productIdentifier, - restored: restored) - ) + data: transactionData ) { result in self.handlePostReceiptResult(result, + transactionData: transactionData, subscriberAttributes: unsyncedAttributes, adServicesToken: adServicesToken) @@ -1227,22 +1245,24 @@ extension PurchasesOrchestrator { let paywall = self.getAndRemovePresentedPaywall() let unsyncedAttributes = self.unsyncedAttributes let adServicesToken = self.attribution.unsyncedAdServicesToken + let transactionData: PurchasedTransactionData = .init( + appUserID: self.appUserID, + presentedOfferingID: offeringID, + presentedPaywall: paywall, + unsyncedAttributes: unsyncedAttributes, + aadAttributionToken: adServicesToken, + storefront: storefront, + source: .init(isRestore: self.allowSharingAppStoreAccount, + initiationSource: initiationSource) + ) let result = await self.transactionPoster.handlePurchasedTransaction( transaction, - data: .init( - appUserID: self.appUserID, - presentedOfferingID: offeringID, - presentedPaywall: paywall, - unsyncedAttributes: unsyncedAttributes, - aadAttributionToken: adServicesToken, - storefront: storefront, - source: .init(isRestore: self.allowSharingAppStoreAccount, - initiationSource: initiationSource) - ) + data: transactionData ) self.handlePostReceiptResult(result, + transactionData: transactionData, subscriberAttributes: unsyncedAttributes, adServicesToken: adServicesToken) diff --git a/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift b/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift index 28a8db289b..bc29401a7a 100644 --- a/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift @@ -61,10 +61,14 @@ class BaseBackendIntegrationTests: TestCase { var proxyURL: String? { return Constants.proxyURL } func configurePurchases() { + Purchases.proxyURL = self.proxyURL.flatMap(URL.init(string:)) + Purchases.logLevel = .verbose + Purchases.configure(withAPIKey: self.apiKey, appUserID: nil, observerMode: Self.observerMode, userDefaults: self.userDefaults, + documentsDirectory: self.documentsDirectory, platformInfo: nil, responseVerificationMode: Self.responseVerificationMode, storeKit2Setting: Self.storeKit2Setting, @@ -173,8 +177,6 @@ private extension BaseBackendIntegrationTests { self.simulateForegroundingApp() Purchases.shared.delegate = self.purchasesDelegate - Purchases.proxyURL = self.proxyURL.flatMap(URL.init(string:)) - Purchases.logLevel = .verbose await self.waitForAnonymousUser() } @@ -218,6 +220,12 @@ private extension BaseBackendIntegrationTests { internalSettings: self) } + var documentsDirectory: URL { + return URL + .cachesDirectory + .appendingPathComponent(UUID().uuidString, conformingTo: .directory) + } + } extension BaseBackendIntegrationTests: InternalDangerousSettingsType { diff --git a/Tests/BackendIntegrationTests/PaywallEventsIntegrationTests.swift b/Tests/BackendIntegrationTests/PaywallEventsIntegrationTests.swift index 81b31e8fe5..583fdf1e0d 100644 --- a/Tests/BackendIntegrationTests/PaywallEventsIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/PaywallEventsIntegrationTests.swift @@ -19,30 +19,105 @@ import XCTest class PaywallEventsIntegrationTests: BaseStoreKitIntegrationTests { - func testPurchasingPackageWithPresentedPaywall() async throws { - let offering = try await self.currentOffering - let paywall = try XCTUnwrap(offering.paywall) - let package = try XCTUnwrap(offering.monthly) - let event: PaywallEvent.Data = .init( - offering: offering, - paywall: paywall, + private var offering: Offering! + private var package: Package! + private var paywall: PaywallData! + private var eventData: PaywallEvent.Data! + + override func setUp() async throws { + try await super.setUp() + + self.offering = try await XCTAsyncUnwrap(try await self.currentOffering) + self.package = try XCTUnwrap(self.offering.monthly) + self.paywall = try XCTUnwrap(self.offering.paywall) + + self.eventData = .init( + offering: self.offering, + paywall: self.paywall, sessionID: .init(), displayMode: .fullScreen, locale: .current, darkMode: true ) + } - try await self.purchases.track(paywallEvent: .view(event)) + func testPurchasingPackageWithPresentedPaywall() async throws { + try await self.purchases.track(paywallEvent: .view(self.eventData)) let transaction = try await XCTAsyncUnwrap(try await self.purchases.purchase(package: package).transaction) + self.verifyTransactionHandled(with: transaction, sessionID: self.eventData.sessionIdentifier) + } + + func testPurchasingPackageAfterClearingPresentedPaywall() async throws { + try await self.purchases.track(paywallEvent: .view(self.eventData)) + try await self.purchases.track(paywallEvent: .close(self.eventData)) + + let transaction = try await XCTAsyncUnwrap(try await self.purchases.purchase(package: self.package).transaction) + + self.verifyTransactionHandled(with: transaction, sessionID: nil) + } + + func testPurchasingAfterAFailureRemembersPresentedPaywall() async throws { + self.testSession.failTransactionsEnabled = true + self.testSession.failureError = .unknown + + try await self.purchases.track(paywallEvent: .view(self.eventData)) + + do { + _ = try await self.purchases.purchase(package: self.package) + fail("Expected error") + } catch { + // Expected error + } + + self.logger.clearMessages() + + self.testSession.failTransactionsEnabled = false + let transaction = try await XCTAsyncUnwrap(try await self.purchases.purchase(package: self.package).transaction) + + self.verifyTransactionHandled(with: transaction, sessionID: self.eventData.sessionIdentifier) + } + + func testFlushingEmptyEvents() async throws { + let result = try await self.purchases.flushPaywallEvents(count: 1) + expect(result) == 0 + } + + func testFlushingEvents() async throws { + try await self.purchases.track(paywallEvent: .view(self.eventData)) + try await self.purchases.track(paywallEvent: .cancel(self.eventData)) + try await self.purchases.track(paywallEvent: .close(self.eventData)) + + let result = try await self.purchases.flushPaywallEvents(count: 3) + expect(result) == 3 + } + + func testFlushingEventsClearsThem() async throws { + try await self.purchases.track(paywallEvent: .view(self.eventData)) + try await self.purchases.track(paywallEvent: .cancel(self.eventData)) + try await self.purchases.track(paywallEvent: .close(self.eventData)) + + _ = try await self.purchases.flushPaywallEvents(count: 3) + let result = try await self.purchases.flushPaywallEvents(count: 10) + expect(result) == 0 + } + +} + +private extension PaywallEventsIntegrationTests { + + func verifyTransactionHandled( + with transaction: StoreTransaction, + sessionID: PaywallEvent.SessionID? + ) { self.logger.verifyMessageWasLogged( Strings.purchase.transaction_poster_handling_transaction( transactionID: transaction.transactionIdentifier, - productID: package.storeProduct.productIdentifier, + productID: self.package.storeProduct.productIdentifier, transactionDate: transaction.purchaseDate, - offeringID: package.offeringIdentifier, - paywallSessionID: event.sessionIdentifier + offeringID: self.package.offeringIdentifier, + paywallSessionID: sessionID ) ) } diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift index 8635c8dd1b..fe472c0997 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift @@ -259,6 +259,59 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { expect(self.backend.invokedPostReceiptDataParameters?.transactionData.source.initiationSource) == .purchase } + func testPurchaseSK1PackageWithPresentedPaywall() async throws { + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) + + let product = try await self.fetchSk1Product() + let storeProduct = try await self.fetchSk1StoreProduct() + let payment = self.storeKit1Wrapper.payment(with: product) + + self.orchestrator.cachePresentedPaywall(Self.paywallEvent) + + _ = await withCheckedContinuation { continuation in + self.orchestrator.purchase( + sk1Product: product, + payment: payment, + package: nil, + wrapper: self.storeKit1Wrapper + ) { transaction, customerInfo, error, userCancelled in + continuation.resume(returning: (transaction, customerInfo, error, userCancelled)) + } + } + + expect(self.backend.invokedPostReceiptDataParameters?.transactionData.presentedPaywall) == Self.paywallEvent + } + + func testFailedSK1PurchaseRemembersPresentedPaywall() async throws { + func purchase() async throws { + let product = try await self.fetchSk1Product() + let payment = self.storeKit1Wrapper.payment(with: product) + + _ = await withCheckedContinuation { continuation in + self.orchestrator.purchase( + sk1Product: product, + payment: payment, + package: nil, + wrapper: self.storeKit1Wrapper + ) { transaction, customerInfo, error, userCancelled in + continuation.resume(returning: (transaction, customerInfo, error, userCancelled)) + } + } + } + + self.orchestrator.cachePresentedPaywall(Self.paywallEvent) + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo + + self.backend.stubbedPostReceiptResult = .failure(.unexpectedBackendResponse(.customerInfoNil)) + try await purchase() + + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) + try await purchase() + + expect(self.backend.invokedPostReceiptDataParameters?.transactionData.presentedPaywall) == Self.paywallEvent + } + func testPurchaseSK1PackageDoesNotPostAdServicesTokenIfNotEnabled() async throws { self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) @@ -662,6 +715,56 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { expect(self.backend.invokedPostReceiptDataParameters?.transactionData.source.initiationSource) == .purchase } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testPurchaseSK2PackageWithPresentedPaywall() async throws { + try AvailabilityChecks.iOS15APIAvailableOrSkipTest() + + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) + self.orchestrator.cachePresentedPaywall(Self.paywallEvent) + + let mockListener = try XCTUnwrap( + self.orchestrator.storeKit2TransactionListener as? MockStoreKit2TransactionListener + ) + mockListener.mockTransaction = .init(try await self.simulateAnyPurchase()) + + let product = try await self.fetchSk2Product() + + _ = try await self.orchestrator.purchase(sk2Product: product, + package: nil, + promotionalOffer: nil) + + expect(self.backend.invokedPostReceiptDataParameters?.transactionData.presentedPaywall) == Self.paywallEvent + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testFailedSK2PurchaseRemembersPresentedPaywall() async throws { + try AvailabilityChecks.iOS15APIAvailableOrSkipTest() + + let mockListener = try XCTUnwrap( + self.orchestrator.storeKit2TransactionListener as? MockStoreKit2TransactionListener + ) + mockListener.mockTransaction = .init(try await self.simulateAnyPurchase()) + + let product = try await self.fetchSk2Product() + + self.orchestrator.cachePresentedPaywall(Self.paywallEvent) + + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo + + self.backend.stubbedPostReceiptResult = .failure(.unexpectedBackendResponse(.customerInfoNil)) + _ = try? await self.orchestrator.purchase(sk2Product: product, + package: nil, + promotionalOffer: nil) + + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) + _ = try await self.orchestrator.purchase(sk2Product: product, + package: nil, + promotionalOffer: nil) + + expect(self.backend.invokedPostReceiptDataParameters?.transactionData.presentedPaywall) == Self.paywallEvent + } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func testSK2PurchaseDoesNotAlwaysRefreshReceiptInProduction() async throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() @@ -1344,4 +1447,14 @@ private extension PurchasesOrchestratorTests { localizedDescription: "Description" ).toStoreProduct() + static let paywallEvent: PaywallEvent.Data = .init( + offeringIdentifier: "offering", + paywallRevision: 5, + sessionID: .init(), + displayMode: .fullScreen, + localeIdentifier: "en_US", + darkMode: true, + date: .init(timeIntervalSince1970: 1694029328) + ) + } diff --git a/Tests/UnitTests/Mocks/MockPaywallEventsManager.swift b/Tests/UnitTests/Mocks/MockPaywallEventsManager.swift index 6283bf6bba..5cceeeead6 100644 --- a/Tests/UnitTests/Mocks/MockPaywallEventsManager.swift +++ b/Tests/UnitTests/Mocks/MockPaywallEventsManager.swift @@ -17,6 +17,6 @@ import Foundation final class MockPaywallEventsManager: PaywallEventsManagerType { func track(paywallEvent: PaywallEvent) async {} - func flushEvents(count: Int) async {} + func flushEvents(count: Int) async throws -> Int { return 0 } } diff --git a/Tests/UnitTests/Paywalls/Events/PaywallEventStoreTests.swift b/Tests/UnitTests/Paywalls/Events/PaywallEventStoreTests.swift index 744163eaf6..ae15cd8219 100644 --- a/Tests/UnitTests/Paywalls/Events/PaywallEventStoreTests.swift +++ b/Tests/UnitTests/Paywalls/Events/PaywallEventStoreTests.swift @@ -33,7 +33,7 @@ class PaywallEventStoreTests: TestCase { // - MARK: - func testCreateDefaultDoesNotThrow() throws { - _ = try PaywallEventStore.createDefault() + _ = try PaywallEventStore.createDefault(documentsDirectory: nil) } // - MARK: store and fetch diff --git a/Tests/UnitTests/Paywalls/Events/PaywallEventsManagerTests.swift b/Tests/UnitTests/Paywalls/Events/PaywallEventsManagerTests.swift index aa116a3ba9..a2d4277250 100644 --- a/Tests/UnitTests/Paywalls/Events/PaywallEventsManagerTests.swift +++ b/Tests/UnitTests/Paywalls/Events/PaywallEventsManagerTests.swift @@ -68,15 +68,17 @@ class PaywallEventsManagerTests: TestCase { // MARK: - flushEvents - func testFlushEmptyStore() async { - await self.manager.flushEvents(count: 1) + func testFlushEmptyStore() async throws { + let result = try await self.manager.flushEvents(count: 1) + expect(result) == 0 expect(self.api.invokedPostPaywallEvents) == false } - func testFlushOneEvent() async { + func testFlushOneEvent() async throws { let event = await self.storeRandomEvent() - await self.manager.flushEvents(count: 1) + let result = try await self.manager.flushEvents(count: 1) + expect(result) == 1 expect(self.api.invokedPostPaywallEvents) == true expect(self.api.invokedPostPaywallEventsParameters) == [[.init(event: event, userID: Self.userID)]] @@ -84,12 +86,15 @@ class PaywallEventsManagerTests: TestCase { await self.verifyEmptyStore() } - func testFlushTwice() async { + func testFlushTwice() async throws { let event1 = await self.storeRandomEvent() let event2 = await self.storeRandomEvent() - await self.manager.flushEvents(count: 1) - await self.manager.flushEvents(count: 1) + let result1 = try await self.manager.flushEvents(count: 1) + let result2 = try await self.manager.flushEvents(count: 1) + + expect(result1) == 1 + expect(result2) == 1 expect(self.api.invokedPostPaywallEvents) == true expect(self.api.invokedPostPaywallEventsParameters) == [ @@ -100,14 +105,15 @@ class PaywallEventsManagerTests: TestCase { await self.verifyEmptyStore() } - func testFlushOnlyOneEventPostsFirstOne() async { + func testFlushOnlyOneEventPostsFirstOne() async throws { let event = await self.storeRandomEvent() let storedEvent: PaywallStoredEvent = .init(event: event, userID: Self.userID) _ = await self.storeRandomEvent() _ = await self.storeRandomEvent() - await self.manager.flushEvents(count: 1) + let result = try await self.manager.flushEvents(count: 1) + expect(result) == 1 expect(self.api.invokedPostPaywallEvents) == true expect(self.api.invokedPostPaywallEventsParameters) == [[storedEvent]] @@ -117,13 +123,19 @@ class PaywallEventsManagerTests: TestCase { expect(events).toNot(contain(storedEvent)) } - func testFlushWithUnsuccessfulPostError() async { + func testFlushWithUnsuccessfulPostError() async throws { let event = await self.storeRandomEvent() let storedEvent: PaywallStoredEvent = .init(event: event, userID: Self.userID) self.api.stubbedPostPaywallEventsCompletionResult = .networkError(.offlineConnection()) - - await self.manager.flushEvents(count: 1) + do { + _ = try await self.manager.flushEvents(count: 1) + fail("Expected error") + } catch BackendError.networkError(.offlineConnection) { + // Expected + } catch { + throw error + } expect(self.api.invokedPostPaywallEvents) == true expect(self.api.invokedPostPaywallEventsParameters) == [[storedEvent]] @@ -131,20 +143,28 @@ class PaywallEventsManagerTests: TestCase { await self.verifyEvents([storedEvent]) } - func testFlushWithSuccessfullySyncedError() async { + func testFlushWithSuccessfullySyncedError() async throws { _ = await self.storeRandomEvent() self.api.stubbedPostPaywallEventsCompletionResult = .networkError( .errorResponse(.defaultResponse, .invalidRequest) ) - await self.manager.flushEvents(count: 1) + do { + _ = try await self.manager.flushEvents(count: 1) + fail("Expected error") + } catch BackendError.networkError(.errorResponse) { + // Expected + } catch { + throw error + } + expect(self.api.invokedPostPaywallEvents) == true await self.verifyEmptyStore() } - func testFlushWithSuccessfullySyncedErrorOnlyDeletesPostedEvents() async { + func testFlushWithSuccessfullySyncedErrorOnlyDeletesPostedEvents() async throws { let event1 = await self.storeRandomEvent() let event2 = await self.storeRandomEvent() @@ -152,7 +172,14 @@ class PaywallEventsManagerTests: TestCase { .errorResponse(.defaultResponse, .invalidRequest) ) - await self.manager.flushEvents(count: 1) + do { + _ = try await self.manager.flushEvents(count: 1) + fail("Expected error") + } catch BackendError.networkError(.errorResponse) { + // Expected + } catch { + throw error + } expect(self.api.invokedPostPaywallEvents) == true expect(self.api.invokedPostPaywallEventsParameters) == [[.init(event: event1, userID: Self.userID)]]