diff --git a/Sources/Diagnostics/DiagnosticsEvent.swift b/Sources/Diagnostics/DiagnosticsEvent.swift index fdb7e254a1..54c6808b54 100644 --- a/Sources/Diagnostics/DiagnosticsEvent.swift +++ b/Sources/Diagnostics/DiagnosticsEvent.swift @@ -33,6 +33,8 @@ extension DiagnosticsEvent { case httpRequestPerformed case customerInfoVerificationResult case maxEventsStoredLimitReached + case applePurchaseAttempt + case checkIntroTrial } @@ -41,9 +43,13 @@ extension DiagnosticsEvent { case verificationResultKey case endpointNameKey case responseTimeMillisKey + case storeKitVersion case successfulKey case responseCodeKey case backendErrorCodeKey + case errorMessageKey + case errorCodeKey + case skErrorDescriptionKey case eTagHitKey } diff --git a/Sources/Diagnostics/DiagnosticsTracker.swift b/Sources/Diagnostics/DiagnosticsTracker.swift index b54f0687a0..89796d1123 100644 --- a/Sources/Diagnostics/DiagnosticsTracker.swift +++ b/Sources/Diagnostics/DiagnosticsTracker.swift @@ -31,6 +31,22 @@ protocol DiagnosticsTrackerType { resultOrigin: HTTPResponseOrigin?, verificationResult: VerificationResult) async + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackPurchaseRequest(wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?) async + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + // swiftlint:disable:next function_parameter_count + func trackCheckIntroTrial(wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?, + responseTime: TimeInterval) async + } @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) @@ -91,6 +107,44 @@ final class DiagnosticsTracker: DiagnosticsTrackerType { ) } + func trackPurchaseRequest(wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?) async { + await track( + DiagnosticsEvent(eventType: .applePurchaseAttempt, + properties: [ + .successfulKey: AnyEncodable(wasSuccessful), + .storeKitVersion: AnyEncodable("store_kit_\(storeKitVersion.debugDescription)"), + .errorMessageKey: AnyEncodable(errorMessage), + .errorCodeKey: AnyEncodable(errorCode), + .skErrorDescriptionKey: AnyEncodable(storeKitErrorDescription) + ], + timestamp: self.dateProvider.now()) + ) + } + + func trackCheckIntroTrial(wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?, + responseTime: TimeInterval) async { + await track( + DiagnosticsEvent(eventType: .applePurchaseAttempt, + properties: [ + .successfulKey: AnyEncodable(wasSuccessful), + .storeKitVersion: AnyEncodable("store_kit_\(storeKitVersion.debugDescription)"), + .errorMessageKey: AnyEncodable(errorMessage), + .errorCodeKey: AnyEncodable(errorCode), + .skErrorDescriptionKey: AnyEncodable(storeKitErrorDescription), + .responseTimeMillisKey: AnyEncodable(responseTime * 1000) + ], + timestamp: self.dateProvider.now()) + ) + } + } @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) diff --git a/Sources/Diagnostics/Networking/DiagnosticsEventsRequest.swift b/Sources/Diagnostics/Networking/DiagnosticsEventsRequest.swift index 6e60f6d1c9..ffb6661845 100644 --- a/Sources/Diagnostics/Networking/DiagnosticsEventsRequest.swift +++ b/Sources/Diagnostics/Networking/DiagnosticsEventsRequest.swift @@ -64,6 +64,8 @@ private extension DiagnosticsEvent.EventType { case .httpRequestPerformed: return "http_request_performed" case .customerInfoVerificationResult: return "customer_info_verification_result" case .maxEventsStoredLimitReached: return "max_events_stored_limit_reached" + case .applePurchaseAttempt: return "apple_purchase_attempt" + case .checkIntroTrial: return "check_intro_trial" } } @@ -80,12 +82,20 @@ private extension DiagnosticsEvent.DiagnosticsPropertyKey { return "endpoint_name" case .responseTimeMillisKey: return "response_time_millis" + case .storeKitVersion: + return "store_kit_version" case .successfulKey: return "successful" case .responseCodeKey: return "response_code" case .backendErrorCodeKey: return "backend_error_code" + case .errorMessageKey: + return "error_message" + case .errorCodeKey: + return "error_code" + case .skErrorDescriptionKey: + return "sk_error_description" case .eTagHitKey: return "etag_hit" } diff --git a/Sources/Error Handling/ErrorUtils.swift b/Sources/Error Handling/ErrorUtils.swift index 7649ab3a70..4d062ce2b9 100644 --- a/Sources/Error Handling/ErrorUtils.swift +++ b/Sources/Error Handling/ErrorUtils.swift @@ -544,6 +544,29 @@ enum ErrorUtils { fileName: fileName, functionName: functionName, line: line) } + static func invalidReceiptError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .invalidReceiptError, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/invalidReceiptError`` code. + * + * - Note: This error is used when there is a problem with the receipt. + */ + static func invalidReceiptError( + withMessage message: String, error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + let errorCode = ErrorCode.invalidReceiptError + return ErrorUtils.error(with: errorCode, + message: message, + underlyingError: error, + fileName: fileName, functionName: functionName, line: line) + } + } extension ErrorUtils { diff --git a/Sources/Error Handling/SKError+Extensions.swift b/Sources/Error Handling/SKError+Extensions.swift index c8123383c0..655d04a250 100644 --- a/Sources/Error Handling/SKError+Extensions.swift +++ b/Sources/Error Handling/SKError+Extensions.swift @@ -91,6 +91,57 @@ extension SKError: PurchasesErrorConvertible { } +extension SKError.Code { + var trackingDescription: String { + switch self { + case .unknown: + return "unknown" + case .clientInvalid: + return "client_invalid" + case .paymentCancelled: + return "payment_cancelled" + case .paymentInvalid: + return "payment_invalid" + case .paymentNotAllowed: + return "payment_not_allowed" + case .storeProductNotAvailable: + return "store_product_not_available" + case .cloudServicePermissionDenied: + return "cloud_service_permission_denied" + case .cloudServiceNetworkConnectionFailed: + return "cloud_service_network_connection_failed" + case .cloudServiceRevoked: + return "cloud_service_revoked" + case .privacyAcknowledgementRequired: + return "privacy_acknowledgement_required" + case .unauthorizedRequestData: + return "unauthorized_request_data" + case .invalidOfferIdentifier: + return "invalid_offer_identifier" + case .invalidSignature: + return "invalid_signature" + case .missingOfferParams: + return "missing_offer_params" + case .invalidOfferPrice: + return "invalid_offer_price" + case .overlayCancelled: + return "overlay_cancelled" + case .overlayInvalidConfiguration: + return "overlay_invalid_configuration" + case .overlayTimeout: + return "overlay_timeout" + case .ineligibleForOffer: + return "ineligible_for_offer" + case .unsupportedPlatform: + return "unsupported_platform" + case .overlayPresentedInBackgroundScene: + return "overlay_presented_in_background_scene" + @unknown default: + return "unknown_future_error" + } + } +} + private extension SKError { enum UndocumentedCode: Int { diff --git a/Sources/Error Handling/StoreKitError+Extensions.swift b/Sources/Error Handling/StoreKitError+Extensions.swift index e02848b9b3..20f3cd1d5b 100644 --- a/Sources/Error Handling/StoreKitError+Extensions.swift +++ b/Sources/Error Handling/StoreKitError+Extensions.swift @@ -47,6 +47,29 @@ extension StoreKitError: PurchasesErrorConvertible { } } + var trackingDescription: String { + switch self { + case .unknown: + return "unknown" + case .userCancelled: + return "user_cancelled" + case .networkError(let urlError): + return "network_error_\(urlError.code.rawValue)" + case .systemError(let error): + return "system_error_\(String(describing: error))" + case .notAvailableInStorefront: + return "not_available_in_storefront" + case .notEntitled: + if #available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 8.5, visionOS 1.0, *) { + return "not_entitled" + } else { + return "unknown" + } + @unknown default: + return "unknown_future_error" + } + } + } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) diff --git a/Sources/Purchasing/IntroEligibilityCalculator.swift b/Sources/Purchasing/IntroEligibilityCalculator.swift index bafca1ac43..75fe229507 100644 --- a/Sources/Purchasing/IntroEligibilityCalculator.swift +++ b/Sources/Purchasing/IntroEligibilityCalculator.swift @@ -28,7 +28,7 @@ class IntroEligibilityCalculator { func checkEligibility(with receiptData: Data, productIdentifiers candidateProductIdentifiers: Set, - completion: @escaping ([String: IntroEligibilityStatus], Error?) -> Void) { + completion: @escaping ([String: IntroEligibilityStatus], PurchasesError?) -> Void) { guard candidateProductIdentifiers.count > 0 else { completion([:], nil) return @@ -73,8 +73,10 @@ class IntroEligibilityCalculator { completion(result, nil) } } catch { + let message = Strings.customerInfo.checking_intro_eligibility_locally_error(error: error).description + let purchasesError = ErrorUtils.invalidReceiptError(withMessage: message, error: error) Logger.error(Strings.customerInfo.checking_intro_eligibility_locally_error(error: error)) - completion([:], error) + completion([:], purchasesError) return } } diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index a82efc7ea5..45df61748f 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -500,7 +500,8 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void storeKit2StorefrontListener: StoreKit2StorefrontListener(delegate: nil), storeKit2ObserverModePurchaseDetector: storeKit2ObserverModePurchaseDetector, storeMessagesHelper: storeMessagesHelper, - diagnosticsSynchronizer: diagnosticsSynchronizer + diagnosticsSynchronizer: diagnosticsSynchronizer, + diagnosticsTracker: diagnosticsTracker ) } else { return .init( @@ -533,7 +534,8 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void backend: backend, currentUserProvider: identityManager, operationDispatcher: operationDispatcher, - productsManager: productsManager) + productsManager: productsManager, + diagnosticsTracker: diagnosticsTracker) ) let paywallCache: PaywallCacheWarmingType? diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index 6ddd4a36ed..8dd7fef09a 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -75,6 +75,7 @@ final class PurchasesOrchestrator { var _storeKit2TransactionListener: Any? var _storeKit2StorefrontListener: Any? var _diagnosticsSynchronizer: Any? + var _diagnosticsTracker: Any? var _storeKit2ObserverModePurchaseDetector: Any? // swiftlint:enable identifier_name @@ -95,6 +96,11 @@ final class PurchasesOrchestrator { return self._diagnosticsSynchronizer as? DiagnosticsSynchronizerType } + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + var diagnosticsTracker: DiagnosticsTrackerType? { + return self._diagnosticsTracker as? DiagnosticsTrackerType + } + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) var storeKit2ObserverModePurchaseDetector: StoreKit2ObserverModePurchaseDetectorType? { return self._storeKit2ObserverModePurchaseDetector as? StoreKit2ObserverModePurchaseDetectorType @@ -122,7 +128,8 @@ final class PurchasesOrchestrator { storeKit2StorefrontListener: StoreKit2StorefrontListener, storeKit2ObserverModePurchaseDetector: StoreKit2ObserverModePurchaseDetectorType, storeMessagesHelper: StoreMessagesHelperType?, - diagnosticsSynchronizer: DiagnosticsSynchronizerType? + diagnosticsSynchronizer: DiagnosticsSynchronizerType?, + diagnosticsTracker: DiagnosticsTrackerType? ) { self.init( productsManager: productsManager, @@ -146,6 +153,7 @@ final class PurchasesOrchestrator { ) self._diagnosticsSynchronizer = diagnosticsSynchronizer + self._diagnosticsTracker = diagnosticsTracker self._storeKit2TransactionListener = storeKit2TransactionListener self._storeKit2StorefrontListener = storeKit2StorefrontListener @@ -415,6 +423,7 @@ final class PurchasesOrchestrator { productIdentifier: productIdentifier, completion: { transaction, customerInfo, error, cancelled in if !cancelled { + self.trackPurchaseEventIfNeeded(storeKitVersion: .storeKit1, error: error) if let error = error { Logger.rcPurchaseError(Strings.purchase.product_purchase_failed( productIdentifier: productIdentifier, @@ -475,12 +484,11 @@ final class PurchasesOrchestrator { } } + // swiftlint:disable function_body_length @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func purchase( - sk2Product: SK2Product, - package: Package?, - promotionalOffer: PromotionalOffer.SignedData? - ) async throws -> PurchaseResultData { + func purchase(sk2Product: SK2Product, + package: Package?, + promotionalOffer: PromotionalOffer.SignedData?) async throws -> PurchaseResultData { let result: Product.PurchaseResult do { @@ -517,10 +525,14 @@ final class PurchasesOrchestrator { userCancelled: true ) } catch let error as PromotionalOffer.SignedData.Error { - throw ErrorUtils.invalidPromotionalOfferError(error: error, - message: error.localizedDescription) + let error = ErrorUtils.invalidPromotionalOfferError(error: error, + message: error.localizedDescription) + self.trackPurchaseEventIfNeeded(storeKitVersion: .storeKit2, error: error.asPublicError) + throw error } catch { - throw ErrorUtils.purchasesError(withStoreKitError: error) + let purchasesError = ErrorUtils.purchasesError(withStoreKitError: error) + self.trackPurchaseEventIfNeeded(storeKitVersion: .storeKit2, error: purchasesError.asPublicError) + throw error } // `userCancelled` above comes from `StoreKitError.userCancelled`. @@ -543,8 +555,13 @@ final class PurchasesOrchestrator { fetchPolicy: .cachedOrFetched) } + if !userCancelled { + self.trackPurchaseEventIfNeeded(storeKitVersion: .storeKit2, error: nil) + } + return (transaction, customerInfo, userCancelled) } + // swiftlint:enable function_body_length @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) private func purchase( @@ -872,6 +889,33 @@ private extension PurchasesOrchestrator { } } + func trackPurchaseEventIfNeeded(storeKitVersion: StoreKitVersion, + error: PublicError?) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), + let diagnosticsTracker = self.diagnosticsTracker { + Task(priority: .background) { + let errorMessage = + (error?.userInfo[NSUnderlyingErrorKey] as? Error)?.localizedDescription ?? error?.localizedDescription + let errorCode = error?.code + let storeKitErrorDescription: String? + + if let skError = error?.userInfo[NSUnderlyingErrorKey] as? SKError { + storeKitErrorDescription = skError.code.trackingDescription + } else if let storeKitError = error?.userInfo[NSUnderlyingErrorKey] as? StoreKitError { + storeKitErrorDescription = storeKitError.trackingDescription + } else { + storeKitErrorDescription = nil + } + + await diagnosticsTracker.trackPurchaseRequest(wasSuccessful: error == nil, + storeKitVersion: storeKitVersion, + errorMessage: errorMessage, + errorCode: errorCode, + storeKitErrorDescription: storeKitErrorDescription) + } + } + } + /// - Parameter restored: whether the transaction state was `.restored` instead of `.purchased`. private func purchaseSource( for productIdentifier: String, diff --git a/Sources/Purchasing/TrialOrIntroPriceEligibilityChecker.swift b/Sources/Purchasing/TrialOrIntroPriceEligibilityChecker.swift index f4d04dc93a..2c8b868be3 100644 --- a/Sources/Purchasing/TrialOrIntroPriceEligibilityChecker.swift +++ b/Sources/Purchasing/TrialOrIntroPriceEligibilityChecker.swift @@ -15,6 +15,7 @@ import Foundation import StoreKit typealias ReceiveIntroEligibilityBlock = ([String: IntroEligibility]) -> Void +typealias ReceiveIntroEligibilityWithErrorBlock = ([String: IntroEligibility], PurchasesError?) -> Void /// A type that can determine `IntroEligibility` for products. protocol TrialOrIntroPriceEligibilityCheckerType: Sendable { @@ -33,6 +34,8 @@ class TrialOrIntroPriceEligibilityChecker: TrialOrIntroPriceEligibilityCheckerTy private let currentUserProvider: CurrentUserProvider private let operationDispatcher: OperationDispatcher private let productsManager: ProductsManagerType + private let diagnosticsTracker: DiagnosticsTrackerType? + private let dateProvider: DateProvider init( systemInfo: SystemInfo, @@ -41,7 +44,9 @@ class TrialOrIntroPriceEligibilityChecker: TrialOrIntroPriceEligibilityCheckerTy backend: Backend, currentUserProvider: CurrentUserProvider, operationDispatcher: OperationDispatcher, - productsManager: ProductsManagerType + productsManager: ProductsManagerType, + diagnosticsTracker: DiagnosticsTrackerType? = nil, + dateProvider: DateProvider = DateProvider() ) { self.systemInfo = systemInfo self.receiptFetcher = receiptFetcher @@ -50,6 +55,8 @@ class TrialOrIntroPriceEligibilityChecker: TrialOrIntroPriceEligibilityCheckerTy self.currentUserProvider = currentUserProvider self.operationDispatcher = operationDispatcher self.productsManager = productsManager + self.diagnosticsTracker = diagnosticsTracker + self.dateProvider = dateProvider } func checkEligibility(productIdentifiers: Set, @@ -69,21 +76,28 @@ class TrialOrIntroPriceEligibilityChecker: TrialOrIntroPriceEligibilityCheckerTy } } + let startTime = self.dateProvider.now() if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *), self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable { Async.call(with: completionBlock) { do { - return try await self.sk2CheckEligibility(productIdentifiers) + let result = try await self.sk2CheckEligibility(productIdentifiers) + self.trackCheckEligibilityIfNeeded(startTime, storeKitVersion: .storeKit2, error: nil) + return result } catch { + let purchasesError = ErrorUtils.purchasesError(withStoreKitError: error) Logger.appleError(Strings.eligibility.unable_to_get_intro_eligibility_for_user(error: error)) - + self.trackCheckEligibilityIfNeeded(startTime, + storeKitVersion: .storeKit2, + error: purchasesError.asPublicError) return productIdentifiers.reduce(into: [:]) { resultDict, productId in resultDict[productId] = IntroEligibility(eligibilityStatus: IntroEligibilityStatus.unknown) } } } } else { - self.sk1CheckEligibility(productIdentifiers) { result in + self.sk1CheckEligibility(productIdentifiers) { result, error in + self.trackCheckEligibilityIfNeeded(startTime, storeKitVersion: .storeKit2, error: error?.asPublicError) self.operationDispatcher.dispatchOnMainActor { completion(result) } @@ -92,22 +106,22 @@ class TrialOrIntroPriceEligibilityChecker: TrialOrIntroPriceEligibilityCheckerTy } func sk1CheckEligibility(_ productIdentifiers: Set, - completion: @escaping ReceiveIntroEligibilityBlock) { + completion: @escaping ReceiveIntroEligibilityWithErrorBlock) { // We don't want to refresh receipts because it will likely prompt the user for their credentials, // and intro eligibility is triggered programmatically. self.receiptFetcher.receiptData(refreshPolicy: .never) { data, _ in if let data = data { self.sk1CheckEligibility(with: data, - productIdentifiers: productIdentifiers) { eligibility in + productIdentifiers: productIdentifiers) { eligibility, error in self.operationDispatcher.dispatchOnMainActor { - completion(eligibility) + completion(eligibility, error) } } } else { self.getIntroEligibility(with: data ?? Data(), - productIdentifiers: productIdentifiers) { eligibility in + productIdentifiers: productIdentifiers) { eligibility, error in self.operationDispatcher.dispatchOnMainActor { - completion(eligibility) + completion(eligibility, error) } } } @@ -162,7 +176,7 @@ private extension TrialOrIntroPriceEligibilityChecker { func sk1CheckEligibility(with receiptData: Data, productIdentifiers: Set, - completion: @escaping ReceiveIntroEligibilityBlock) { + completion: @escaping ReceiveIntroEligibilityWithErrorBlock) { introEligibilityCalculator .checkEligibility(with: receiptData, productIdentifiers: productIdentifiers) { receivedEligibility, error in @@ -175,16 +189,16 @@ private extension TrialOrIntroPriceEligibilityChecker { } let convertedEligibility = receivedEligibility.mapValues(IntroEligibility.init) - + self.operationDispatcher.dispatchOnMainThread { - completion(convertedEligibility) + completion(convertedEligibility, error) } } } func getIntroEligibility(with receiptData: Data, productIdentifiers: Set, - completion: @escaping ReceiveIntroEligibilityBlock) { + completion: @escaping ReceiveIntroEligibilityWithErrorBlock) { if #available(iOS 11.2, macOS 10.13.2, macCatalyst 13.0, tvOS 11.2, watchOS 6.2, *) { // Products that don't have an introductory discount don't need to be sent to the backend // Step 1: Filter out products without introductory discount and give .noIntroOfferExists status @@ -196,9 +210,9 @@ private extension TrialOrIntroPriceEligibilityChecker { } self.getIntroEligibilityFromBackend(with: receiptData, - productIdentifiers: nilProductIdentifiers) { backendResults in + productIdentifiers: nilProductIdentifiers) { backendResults, error in let results = onDeviceResults + backendResults - completion(results) + completion(results, error) } } } else { @@ -208,6 +222,36 @@ private extension TrialOrIntroPriceEligibilityChecker { } } + func trackCheckEligibilityIfNeeded(_ startTime: Date, + storeKitVersion: StoreKitVersion, + error: PublicError?) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), + let diagnosticsTracker = self.diagnosticsTracker { + let responseTime = self.dateProvider.now().timeIntervalSince(startTime) + Task(priority: .background) { + let errorMessage = + (error?.userInfo[NSUnderlyingErrorKey] as? Error)?.localizedDescription ?? error?.localizedDescription + let errorCode = error?.code + let storeKitErrorDescription: String? + + if let skError = error?.userInfo[NSUnderlyingErrorKey] as? SKError { + storeKitErrorDescription = skError.code.trackingDescription + } else if let storeKitError = error?.userInfo[NSUnderlyingErrorKey] as? StoreKitError { + storeKitErrorDescription = storeKitError.trackingDescription + } else { + storeKitErrorDescription = nil + } + + await diagnosticsTracker.trackCheckIntroTrial(wasSuccessful: error == nil, + storeKitVersion: storeKitVersion, + errorMessage: errorMessage, + errorCode: errorCode, + storeKitErrorDescription: storeKitErrorDescription, + responseTime: responseTime) + } + } + } + } extension TrialOrIntroPriceEligibilityChecker { @@ -227,27 +271,25 @@ extension TrialOrIntroPriceEligibilityChecker { func getIntroEligibilityFromBackend(with receiptData: Data, productIdentifiers: Set, - completion: @escaping ReceiveIntroEligibilityBlock) { + completion: @escaping ReceiveIntroEligibilityWithErrorBlock) { if productIdentifiers.isEmpty { - completion([:]) + completion([:], nil) return } self.backend.offerings.getIntroEligibility(appUserID: self.appUserID, receiptData: receiptData, productIdentifiers: productIdentifiers) { backendResult, error in - let result: [String: IntroEligibility] = { - if let error = error { - Logger.error(Strings.eligibility.unable_to_get_intro_eligibility_for_user(error: error)) - return productIdentifiers - .dictionaryWithValues { _ in IntroEligibility(eligibilityStatus: .unknown) } - } else { - return backendResult - } - }() + let result: [String: IntroEligibility] + if let error = error { + Logger.error(Strings.eligibility.unable_to_get_intro_eligibility_for_user(error: error)) + result = productIdentifiers.dictionaryWithValues { _ in IntroEligibility(eligibilityStatus: .unknown) } + } else { + result = backendResult + } self.operationDispatcher.dispatchOnMainThread { - completion(result) + completion(result, error?.asPurchasesError) } } } diff --git a/Tests/StoreKitUnitTests/BasePurchasesOrchestratorTests.swift b/Tests/StoreKitUnitTests/BasePurchasesOrchestratorTests.swift index fb6bf639d5..ee95d73fbf 100644 --- a/Tests/StoreKitUnitTests/BasePurchasesOrchestratorTests.swift +++ b/Tests/StoreKitUnitTests/BasePurchasesOrchestratorTests.swift @@ -109,17 +109,6 @@ class BasePurchasesOrchestratorTests: StoreKitConfigTestCase { } - fileprivate func setUpDiagnosticSynchronizer() { - if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { - self.orchestrator._diagnosticsSynchronizer = MockDiagnosticsSynchronizer() - } - } - - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - var mockDiagnosticsSynchronizer: MockDiagnosticsSynchronizer? { - return self.orchestrator.diagnosticsSynchronizer as? MockDiagnosticsSynchronizer - } - func setUpStoreKit2Listener() { if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { self.orchestrator._storeKit2TransactionListener = MockStoreKit2TransactionListener() @@ -190,7 +179,8 @@ class BasePurchasesOrchestratorTests: StoreKitConfigTestCase { storeKit2TransactionListener: StoreKit2TransactionListenerType, storeKit2StorefrontListener: StoreKit2StorefrontListener, storeKit2ObserverModePurchaseDetector: StoreKit2ObserverModePurchaseDetectorType, - diagnosticsSynchronizer: DiagnosticsSynchronizerType? = nil + diagnosticsSynchronizer: DiagnosticsSynchronizerType? = nil, + diagnosticsTracker: DiagnosticsTrackerType? = nil ) { self.orchestrator = PurchasesOrchestrator( productsManager: self.productsManager, @@ -214,7 +204,8 @@ class BasePurchasesOrchestratorTests: StoreKitConfigTestCase { storeKit2StorefrontListener: storeKit2StorefrontListener, storeKit2ObserverModePurchaseDetector: storeKit2ObserverModePurchaseDetector, storeMessagesHelper: self.mockStoreMessagesHelper, - diagnosticsSynchronizer: diagnosticsSynchronizer + diagnosticsSynchronizer: diagnosticsSynchronizer, + diagnosticsTracker: diagnosticsTracker ) self.storeKit1Wrapper.delegate = self.orchestrator } diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorSK1Tests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorSK1Tests.swift index fedbea1d5c..9c9d33dc97 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorSK1Tests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorSK1Tests.swift @@ -591,7 +591,6 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr } func testSyncPurchasesPostsReceiptIfNoTransactionsAndEmptyOriginalPurchaseDate() async throws { - self.mockTransactionFetcher.stubbedFirstVerifiedTransaction = nil self.customerInfoManager.stubbedCachedCustomerInfoResult = CustomerInfo.missingOriginalPurchaseDate self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) @@ -675,3 +674,101 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr } } + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +class PurchasesOrchestratorSK1TrackingTests: PurchasesOrchestratorSK1Tests { + + func testPurchaseSK1TracksCorrectly() async throws { + try AvailabilityChecks.iOS16APIAvailableOrSkipTest() + + let transactionListener = MockStoreKit2TransactionListener() + let storeKit2ObserverModePurchaseDetector = MockStoreKit2ObserverModePurchaseDetector() + let diagnosticsSynchronizer = MockDiagnosticsSynchronizer() + let diagnosticsTracker = MockDiagnosticsTracker() + + self.setUpOrchestrator(storeKit2TransactionListener: transactionListener, + storeKit2StorefrontListener: StoreKit2StorefrontListener(delegate: nil), + storeKit2ObserverModePurchaseDetector: storeKit2ObserverModePurchaseDetector, + diagnosticsSynchronizer: diagnosticsSynchronizer, + diagnosticsTracker: diagnosticsTracker) + + backend.stubbedPostReceiptResult = .success(mockCustomerInfo) + + let product = try await self.fetchSk1Product() + let payment = storeKit1Wrapper.payment(with: product) + + let (transaction, _, _, _) = await withCheckedContinuation { continuation in + orchestrator.purchase(sk1Product: product, + payment: payment, + package: nil, + wrapper: self.storeKit1Wrapper) { transaction, customerInfo, error, userCancelled in + continuation.resume(returning: (transaction, customerInfo, error, userCancelled)) + } + } + + expect(transaction).toNot(beNil()) + try await asyncWait( + description: "Diagnostics tracker should have been called", + timeout: .seconds(4), + pollInterval: .milliseconds(100) + ) { [diagnosticsTracker = diagnosticsTracker] in + diagnosticsTracker.trackedPurchaseRequestParams.value.count == 1 + } + + let params = try XCTUnwrap(diagnosticsTracker.trackedPurchaseRequestParams.value.first) + expect(params.wasSuccessful).to(beTrue()) + expect(params.storeKitVersion) == .storeKit1 + expect(params.errorMessage).to(beNil()) + expect(params.errorCode).to(beNil()) + expect(params.storeKitErrorDescription).to(beNil()) + } + + func testPurchaseWithInvalidPromotionalOfferSignatureTracksError() async throws { + try AvailabilityChecks.iOS16APIAvailableOrSkipTest() + + let transactionListener = MockStoreKit2TransactionListener() + let storeKit2ObserverModePurchaseDetector = MockStoreKit2ObserverModePurchaseDetector() + let diagnosticsSynchronizer = MockDiagnosticsSynchronizer() + let diagnosticsTracker = MockDiagnosticsTracker() + + self.setUpOrchestrator(storeKit2TransactionListener: transactionListener, + storeKit2StorefrontListener: StoreKit2StorefrontListener(delegate: nil), + storeKit2ObserverModePurchaseDetector: storeKit2ObserverModePurchaseDetector, + diagnosticsSynchronizer: diagnosticsSynchronizer, + diagnosticsTracker: diagnosticsTracker) + + storeKit1Wrapper.mockAddPaymentTransactionState = .failed + storeKit1Wrapper.mockTransactionError = NSError(domain: SKErrorDomain, + code: SKError.Code.invalidSignature.rawValue) + let product = try await self.fetchSk1Product() + let offer = PromotionalOffer.SignedData(identifier: "", + keyIdentifier: "", + nonce: UUID(), + signature: "", + timestamp: 0) + + let (transaction, _, _, _) = await withCheckedContinuation { continuation in + orchestrator.purchase(sk1Product: product, + promotionalOffer: offer, + package: nil, + wrapper: self.storeKit1Wrapper) { transaction, customerInfo, error, userCancelled in + continuation.resume(returning: (transaction, customerInfo, error, userCancelled)) + } + } + expect(transaction).toNot(beNil()) + try await asyncWait( + description: "Diagnostics tracker should have been called", + timeout: .seconds(4), + pollInterval: .milliseconds(100) + ) { [diagnosticsTracker = diagnosticsTracker] in + diagnosticsTracker.trackedPurchaseRequestParams.value.count == 1 + } + let params = try XCTUnwrap(diagnosticsTracker.trackedPurchaseRequestParams.value.first) + expect(params.wasSuccessful).to(beFalse()) + expect(params.storeKitVersion) == .storeKit1 + expect(params.errorMessage) == "The operation couldn’t be completed. (SKErrorDomain error 12.)" + expect(params.errorCode) == ErrorCode.invalidPromotionalOfferError.rawValue + expect(params.storeKitErrorDescription) == SKError.Code.invalidSignature.trackingDescription + } + +} diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift index b4706add06..0f2df7030d 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift @@ -758,4 +758,135 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr expect(error).to(matchError(expectedError.asPurchasesError)) } } + + // MARK: - Purchase tracks + + func testPurchaseSK2TracksCorrectly() async throws { + try AvailabilityChecks.iOS16APIAvailableOrSkipTest() + + let transactionListener = MockStoreKit2TransactionListener() + let storeKit2ObserverModePurchaseDetector = MockStoreKit2ObserverModePurchaseDetector() + let diagnosticsSynchronizer = MockDiagnosticsSynchronizer() + let diagnosticsTracker = MockDiagnosticsTracker() + + self.setUpOrchestrator(storeKit2TransactionListener: transactionListener, + storeKit2StorefrontListener: StoreKit2StorefrontListener(delegate: nil), + storeKit2ObserverModePurchaseDetector: storeKit2ObserverModePurchaseDetector, + diagnosticsSynchronizer: diagnosticsSynchronizer, + diagnosticsTracker: diagnosticsTracker) + + backend.stubbedPostReceiptResult = .success(mockCustomerInfo) + let mockTransaction = try await self.simulateAnyPurchase() + mockStoreKit2TransactionListener?.mockTransaction = .init(mockTransaction.underlyingTransaction) + + let product = try await self.fetchSk2Product() + let (transaction, _, _) = try await orchestrator.purchase(sk2Product: product, + package: nil, + promotionalOffer: nil) + + expect(transaction).toNot(beNil()) + try await asyncWait( + description: "Diagnostics tracker should have been called", + timeout: .seconds(4), + pollInterval: .milliseconds(100) + ) { [diagnosticsTracker = diagnosticsTracker] in + diagnosticsTracker.trackedPurchaseRequestParams.value.count == 1 + } + + let params = try XCTUnwrap(diagnosticsTracker.trackedPurchaseRequestParams.value.first) + expect(params.wasSuccessful).to(beTrue()) + expect(params.storeKitVersion) == .storeKit2 + expect(params.errorMessage).to(beNil()) + expect(params.errorCode).to(beNil()) + expect(params.storeKitErrorDescription).to(beNil()) + } + + func testPurchaseWithInvalidPromotionalOfferSignatureTracksError() async throws { + try AvailabilityChecks.iOS16APIAvailableOrSkipTest() + + let transactionListener = MockStoreKit2TransactionListener() + let storeKit2ObserverModePurchaseDetector = MockStoreKit2ObserverModePurchaseDetector() + let diagnosticsSynchronizer = MockDiagnosticsSynchronizer() + let diagnosticsTracker = MockDiagnosticsTracker() + + self.setUpOrchestrator(storeKit2TransactionListener: transactionListener, + storeKit2StorefrontListener: StoreKit2StorefrontListener(delegate: nil), + storeKit2ObserverModePurchaseDetector: storeKit2ObserverModePurchaseDetector, + diagnosticsSynchronizer: diagnosticsSynchronizer, + diagnosticsTracker: diagnosticsTracker) + + let product = try await self.fetchSk2Product() + let offer = PromotionalOffer.SignedData( + identifier: "identifier \(Int.random(in: 0..<1000))", + keyIdentifier: "key identifier \(Int.random(in: 0..<1000))", + nonce: .init(), + // This should be base64 + signature: "signature \(Int.random(in: 0..<1000))", + timestamp: Int.random(in: 0..<1000) + ) + do { + _ = try await orchestrator.purchase(sk2Product: product, package: nil, promotionalOffer: offer) + XCTFail("Expected error") + } catch { + try await asyncWait( + description: "Diagnostics tracker should have been called", + timeout: .seconds(4), + pollInterval: .milliseconds(100) + ) { [diagnosticsTracker = diagnosticsTracker] in + diagnosticsTracker.trackedPurchaseRequestParams.value.count == 1 + } + + let params = try XCTUnwrap(diagnosticsTracker.trackedPurchaseRequestParams.value.first) + expect(params.wasSuccessful).to(beFalse()) + expect(params.storeKitVersion) == .storeKit2 + expect(params.errorMessage) + .to(contain("The signature generated by RevenueCat could not be decoded: \(offer.signature)")) + expect(params.errorCode) == ErrorCode.invalidPromotionalOfferError.rawValue + expect(params.storeKitErrorDescription).to(beNil()) + } + } + + #if swift(>=5.9) + @available(iOS 17.0, tvOS 17.0, macOS 14.0, watchOS 10.0, *) + func testPurchaseWithSimulatedErrorTracksError() async throws { + try AvailabilityChecks.iOS16APIAvailableOrSkipTest() + try await self.testSession.setSimulatedError(.generic(.unknown), forAPI: .purchase) + + let transactionListener = MockStoreKit2TransactionListener() + let storeKit2ObserverModePurchaseDetector = MockStoreKit2ObserverModePurchaseDetector() + let diagnosticsSynchronizer = MockDiagnosticsSynchronizer() + let diagnosticsTracker = MockDiagnosticsTracker() + + self.setUpOrchestrator(storeKit2TransactionListener: transactionListener, + storeKit2StorefrontListener: StoreKit2StorefrontListener(delegate: nil), + storeKit2ObserverModePurchaseDetector: storeKit2ObserverModePurchaseDetector, + diagnosticsSynchronizer: diagnosticsSynchronizer, + diagnosticsTracker: diagnosticsTracker) + + let product = try await self.fetchSk2Product() + + do { + let (transaction, _, _) = try await orchestrator.purchase(sk2Product: product, + package: nil, + promotionalOffer: nil) + XCTFail("Expected error") + } catch { + try await asyncWait( + description: "Diagnostics tracker should have been called", + timeout: .seconds(4), + pollInterval: .milliseconds(100) + ) { [diagnosticsTracker = diagnosticsTracker] in + diagnosticsTracker.trackedPurchaseRequestParams.value.count == 1 + } + + let params = try XCTUnwrap(diagnosticsTracker.trackedPurchaseRequestParams.value.first) + expect(params.wasSuccessful).to(beFalse()) + expect(params.storeKitVersion) == .storeKit2 + expect(params.errorMessage) == "Unable to Complete Request" + expect(params.errorCode) == ErrorCode.storeProblemError.rawValue + expect(params.storeKitErrorDescription) == StoreKitError.unknown.trackingDescription + } + } + #endif + } diff --git a/Tests/StoreKitUnitTests/TrialOrIntroPriceEligibilityCheckerSK1Tests.swift b/Tests/StoreKitUnitTests/TrialOrIntroPriceEligibilityCheckerSK1Tests.swift index 4b180ed36b..36415f2d79 100644 --- a/Tests/StoreKitUnitTests/TrialOrIntroPriceEligibilityCheckerSK1Tests.swift +++ b/Tests/StoreKitUnitTests/TrialOrIntroPriceEligibilityCheckerSK1Tests.swift @@ -60,7 +60,7 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase { self.mockIntroEligibilityCalculator.stubbedCheckTrialOrIntroDiscountEligibilityResult = ([:], nil) waitUntil { completion in - self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([]) { _ in + self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([]) { _, _ in completion() } } @@ -71,7 +71,7 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase { expect(self.receiptFetcher.receiptDataCalled) == false - self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([]) { _ in } + self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([]) { _, _ in } expect(self.receiptFetcher.receiptDataCalled) == true expect(self.receiptFetcher.receiptDataReceivedRefreshPolicy) == .never @@ -81,11 +81,17 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase { let stubbedEligibility = ["product_id": IntroEligibilityStatus.eligible] mockIntroEligibilityCalculator.stubbedCheckTrialOrIntroDiscountEligibilityResult = (stubbedEligibility, nil) - let eligibilities = waitUntilValue { completed in - self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([], completion: completed) + var capturedEligibilities: [String: IntroEligibility]? + + waitUntil { completed in + self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([]) { eligibilities, error in + capturedEligibilities = eligibilities + completed() + } } - expect(eligibilities).to(haveCount(1)) + let receivedEligibilities = try XCTUnwrap(capturedEligibilities) + expect(receivedEligibilities).to(haveCount(1)) } func testSK1EligibilityProductsWithKnownIntroEligibilityStatus() throws { @@ -119,20 +125,23 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase { func testSK1EligibilityIsFetchedFromBackendIfErrorCalculatingEligibilityAndStoreKitDoesNotHaveIt() throws { self.mockProductsManager.stubbedProductsCompletionResult = .success([]) - let stubbedError = NSError(domain: RCPurchasesErrorCodeDomain, - code: ErrorCode.invalidAppUserIdError.rawValue, - userInfo: [:]) + let stubbedError = ErrorUtils.missingAppUserIDError() mockIntroEligibilityCalculator.stubbedCheckTrialOrIntroDiscountEligibilityResult = ([:], stubbedError) let productId = "product_id" let stubbedEligibility = [productId: IntroEligibility(eligibilityStatus: IntroEligibilityStatus.eligible)] mockOfferingsAPI.stubbedGetIntroEligibilityCompletionResult = (stubbedEligibility, nil) - let eligibilities = waitUntilValue { completed in - self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([productId], completion: completed) + var capturedEligibilities: [String: IntroEligibility]? + + waitUntil { completed in + self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([productId]) { eligibilities, error in + capturedEligibilities = eligibilities + completed() + } } - let receivedEligibilities = try XCTUnwrap(eligibilities) + let receivedEligibilities = try XCTUnwrap(capturedEligibilities) expect(receivedEligibilities).to(haveCount(1)) expect(receivedEligibilities[productId]?.status) == IntroEligibilityStatus.eligible @@ -140,9 +149,7 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase { } func testSK1EligibilityIsNotFetchedFromBackendIfEligibilityAlreadyExists() throws { - let stubbedError = NSError(domain: RCPurchasesErrorCodeDomain, - code: ErrorCode.invalidAppUserIdError.rawValue, - userInfo: [:]) + let stubbedError = ErrorUtils.missingAppUserIDError() mockIntroEligibilityCalculator.stubbedCheckTrialOrIntroDiscountEligibilityResult = ([:], stubbedError) let sk1Product = MockSK1Product(mockProductIdentifier: "product_id") @@ -157,11 +164,16 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase { let stubbedEligibility = [productId: IntroEligibility(eligibilityStatus: IntroEligibilityStatus.eligible)] mockOfferingsAPI.stubbedGetIntroEligibilityCompletionResult = (stubbedEligibility, nil) - let eligibilities = waitUntilValue { completed in - self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([productId], completion: completed) + var capturedEligibilities: [String: IntroEligibility]? + + waitUntil { completed in + self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([productId]) { eligibilities, error in + capturedEligibilities = eligibilities + completed() + } } - let receivedEligibilities = try XCTUnwrap(eligibilities) + let receivedEligibilities = try XCTUnwrap(capturedEligibilities) expect(receivedEligibilities).to(haveCount(1)) expect(receivedEligibilities[productId]?.status) == IntroEligibilityStatus.noIntroOfferExists @@ -171,23 +183,27 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase { func testSK1ErrorFetchingFromBackendAfterErrorCalculatingEligibility() throws { self.mockProductsManager.stubbedProductsCompletionResult = .success([]) let productId = "product_id" - - let stubbedError: BackendError = .networkError( + let backendError = BackendError.networkError( .errorResponse(.init(code: .invalidAPIKey, originalCode: BackendErrorCode.invalidAPIKey.rawValue, message: nil), 400) ) + let stubbedError = backendError.asPurchasesError mockIntroEligibilityCalculator.stubbedCheckTrialOrIntroDiscountEligibilityResult = ([:], stubbedError) - mockOfferingsAPI.stubbedGetIntroEligibilityCompletionResult = ([:], stubbedError) + mockOfferingsAPI.stubbedGetIntroEligibilityCompletionResult = ([:], backendError) - let eligibilities = waitUntilValue { completed in - self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([productId], completion: completed) + var capturedEligibilities: [String: IntroEligibility]? + + waitUntil { completed in + self.trialOrIntroPriceEligibilityChecker.sk1CheckEligibility([productId]) { eligibilities, error in + capturedEligibilities = eligibilities + completed() + } } - expect(eligibilities).toEventuallyNot(beNil()) - let receivedEligibilities = try XCTUnwrap(eligibilities) + let receivedEligibilities = try XCTUnwrap(capturedEligibilities) expect(receivedEligibilities).to(haveCount(1)) expect(receivedEligibilities[productId]?.status) == IntroEligibilityStatus.unknown } diff --git a/Tests/StoreKitUnitTests/TrialOrIntroPriceEligibilityCheckerSK2Tests.swift b/Tests/StoreKitUnitTests/TrialOrIntroPriceEligibilityCheckerSK2Tests.swift index a137c3a53a..24aea247ed 100644 --- a/Tests/StoreKitUnitTests/TrialOrIntroPriceEligibilityCheckerSK2Tests.swift +++ b/Tests/StoreKitUnitTests/TrialOrIntroPriceEligibilityCheckerSK2Tests.swift @@ -26,6 +26,7 @@ class TrialOrIntroPriceEligibilityCheckerSK2Tests: StoreKitConfigTestCase { var mockBackend: MockBackend! var mockProductsManager: MockProductsManager! var mockSystemInfo: MockSystemInfo! + var diagnosticsTracker: DiagnosticsTrackerType! override func setUpWithError() throws { try super.setUpWithError() @@ -52,7 +53,8 @@ class TrialOrIntroPriceEligibilityCheckerSK2Tests: StoreKitConfigTestCase { backend: mockBackend, currentUserProvider: currentUserProvider, operationDispatcher: mockOperationDispatcher, - productsManager: mockProductsManager + productsManager: mockProductsManager, + diagnosticsTracker: MockDiagnosticsTracker() ) } diff --git a/Tests/UnitTests/Identity/CustomerInfoManagerTests.swift b/Tests/UnitTests/Identity/CustomerInfoManagerTests.swift index a4fdceade3..80bd9b21ff 100644 --- a/Tests/UnitTests/Identity/CustomerInfoManagerTests.swift +++ b/Tests/UnitTests/Identity/CustomerInfoManagerTests.swift @@ -721,7 +721,7 @@ class CustomerInfoVerificationTrackingTests: BaseCustomerInfoManagerTests { func testTracksCustomerInfoVerificationResultIfNeeded() { self.customerInfoManager.cache(customerInfo: self.mockCustomerInfo, appUserID: "myUser") - expect(self.mockDiagnosticsTracker.trackedCustomerInfo.count).toEventually(equal(1)) + expect(self.mockDiagnosticsTracker.trackedCustomerInfo.value.count).toEventually(equal(1)) } func testDoesNotTrackCustomerInfoResultIfCustomerInfoDoesNotChange() { @@ -729,7 +729,7 @@ class CustomerInfoVerificationTrackingTests: BaseCustomerInfoManagerTests { expect(self.customerInfoManager.lastSentCustomerInfo) === self.mockCustomerInfo self.customerInfoManager.cache(customerInfo: self.mockCustomerInfo, appUserID: "myUser") - expect(self.mockDiagnosticsTracker.trackedCustomerInfo.count).toEventually(equal(0)) + expect(self.mockDiagnosticsTracker.trackedCustomerInfo.value.count).toEventually(equal(0)) } } diff --git a/Tests/UnitTests/Mocks/MockDiagnosticsTracker.swift b/Tests/UnitTests/Mocks/MockDiagnosticsTracker.swift index 5bfa5e6b84..160f049931 100644 --- a/Tests/UnitTests/Mocks/MockDiagnosticsTracker.swift +++ b/Tests/UnitTests/Mocks/MockDiagnosticsTracker.swift @@ -14,27 +14,30 @@ import Foundation @testable import RevenueCat -@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) -class MockDiagnosticsTracker: DiagnosticsTrackerType { +final class MockDiagnosticsTracker: DiagnosticsTrackerType, Sendable { - private(set) var trackedEvents: [DiagnosticsEvent] = [] - private(set) var trackedCustomerInfo: [CustomerInfo] = [] + let trackedEvents: Atomic<[DiagnosticsEvent]> = .init([]) + let trackedCustomerInfo: Atomic<[CustomerInfo]> = .init([]) + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func track(_ event: DiagnosticsEvent) async { - trackedEvents.append(event) + self.trackedEvents.modify { $0.append(event) } } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func trackCustomerInfoVerificationResultIfNeeded( _ customerInfo: RevenueCat.CustomerInfo ) async { - trackedCustomerInfo.append(customerInfo) + self.trackedCustomerInfo.modify { $0.append(customerInfo) } } - private(set) var trackedHttpRequestPerformedParams: [ + let trackedHttpRequestPerformedParams: Atomic<[ // swiftlint:disable:next large_tuple (String, TimeInterval, Bool, Int, Int?, HTTPResponseOrigin?, VerificationResult) - ] = [] + ]> = .init([]) + // swiftlint:disable:next function_parameter_count + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func trackHttpRequestPerformed(endpointName: String, responseTime: TimeInterval, wasSuccessful: Bool, @@ -42,15 +45,72 @@ class MockDiagnosticsTracker: DiagnosticsTrackerType { backendErrorCode: Int?, resultOrigin: HTTPResponseOrigin?, verificationResult: VerificationResult) async { - self.trackedHttpRequestPerformedParams.append( - (endpointName, - responseTime, - wasSuccessful, - responseCode, - backendErrorCode, - resultOrigin, - verificationResult) - ) + self.trackedHttpRequestPerformedParams.modify { + $0.append( + (endpointName, + responseTime, + wasSuccessful, + responseCode, + backendErrorCode, + resultOrigin, + verificationResult) + ) + } + } + + let trackedPurchaseRequestParams: Atomic<[ + // swiftlint:disable:next large_tuple + (wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?) + ]> = .init([]) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackPurchaseRequest(wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?) async { + self.trackedPurchaseRequestParams.modify { + $0.append( + (wasSuccessful, + storeKitVersion, + errorMessage, + errorCode, + storeKitErrorDescription) + ) + } + } + + let trackedCheckIntroTrialParams: Atomic<[ + // swiftlint:disable:next large_tuple + (wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?, + responseTime: TimeInterval) + ]> = .init([]) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackCheckIntroTrial(wasSuccessful: Bool, + storeKitVersion: RevenueCat.StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?, + responseTime: TimeInterval) async { + self.trackedCheckIntroTrialParams.modify { + $0.append( + (wasSuccessful, + storeKitVersion, + errorMessage, + errorCode, + storeKitErrorDescription, + responseTime) + ) + } } } diff --git a/Tests/UnitTests/Mocks/MockIntroEligibilityCalculator.swift b/Tests/UnitTests/Mocks/MockIntroEligibilityCalculator.swift index 1bcfa9e7d2..f6d81fab18 100644 --- a/Tests/UnitTests/Mocks/MockIntroEligibilityCalculator.swift +++ b/Tests/UnitTests/Mocks/MockIntroEligibilityCalculator.swift @@ -18,11 +18,11 @@ class MockIntroEligibilityCalculator: IntroEligibilityCalculator { candidateProductIdentifiers: Set)? var invokedCheckTrialOrIntroDiscountEligibilityParametersList = [(receiptData: Data, candidateProductIdentifiers: Set)]() - var stubbedCheckTrialOrIntroDiscountEligibilityResult: ([String: IntroEligibilityStatus], Error?)? + var stubbedCheckTrialOrIntroDiscountEligibilityResult: ([String: IntroEligibilityStatus], PurchasesError?)? override func checkEligibility(with receiptData: Data, productIdentifiers candidateProductIdentifiers: Set, - completion: @escaping ([String: IntroEligibilityStatus], Error?) -> Void) { + completion: @escaping ([String: IntroEligibilityStatus], PurchasesError?) -> Void) { invokedCheckTrialOrIntroDiscountEligibility = true invokedCheckTrialOrIntroDiscountEligibilityCount += 1 invokedCheckTrialOrIntroDiscountEligibilityParameters = (receiptData, candidateProductIdentifiers) diff --git a/Tests/UnitTests/Mocks/MockTrialOrIntroPriceEligibilityChecker.swift b/Tests/UnitTests/Mocks/MockTrialOrIntroPriceEligibilityChecker.swift index a46e53917e..ff27a9b3bf 100644 --- a/Tests/UnitTests/Mocks/MockTrialOrIntroPriceEligibilityChecker.swift +++ b/Tests/UnitTests/Mocks/MockTrialOrIntroPriceEligibilityChecker.swift @@ -52,14 +52,16 @@ class MockTrialOrIntroPriceEligibilityChecker: TrialOrIntroPriceEligibilityCheck var invokedSk1checkTrialOrIntroPriceEligibilityParameters: (productIdentifiers: Set, Void)? var invokedSk1checkTrialOrIntroPriceEligibilityParametersList = [(productIdentifiers: Set, Void)]() var stubbedSk1checkTrialOrIntroPriceEligibilityReceiveEligibilityResult: [String: IntroEligibility] = [:] + var stubbedSk1checkTrialOrIntroPriceEligibilityReceiveEligibilityError: PurchasesError? = nil override func sk1CheckEligibility(_ productIdentifiers: Set, - completion: @escaping ReceiveIntroEligibilityBlock) { + completion: @escaping ReceiveIntroEligibilityWithErrorBlock) { invokedSk1checkTrialOrIntroPriceEligibility = true invokedSk1checkTrialOrIntroPriceEligibilityCount += 1 invokedSk1checkTrialOrIntroPriceEligibilityParameters = (productIdentifiers, ()) invokedSk1checkTrialOrIntroPriceEligibilityParametersList.append((productIdentifiers, ())) - completion(stubbedSk1checkTrialOrIntroPriceEligibilityReceiveEligibilityResult) + completion(stubbedSk1checkTrialOrIntroPriceEligibilityReceiveEligibilityResult, + stubbedSk1checkTrialOrIntroPriceEligibilityReceiveEligibilityError) } var invokedSk2checkTrialOrIntroPriceEligibility = false diff --git a/Tests/UnitTests/Networking/HTTPClientTests.swift b/Tests/UnitTests/Networking/HTTPClientTests.swift index 661396e033..42081a4ad6 100644 --- a/Tests/UnitTests/Networking/HTTPClientTests.swift +++ b/Tests/UnitTests/Networking/HTTPClientTests.swift @@ -1624,8 +1624,8 @@ final class HTTPClientTests: BaseHTTPClientTests { // swiftlint:disable:next force_cast let mockDiagnosticsTracker = self.diagnosticsTracker as! MockDiagnosticsTracker - expect(mockDiagnosticsTracker.trackedHttpRequestPerformedParams.count).toEventually(equal(1)) - guard let trackedParams = mockDiagnosticsTracker.trackedHttpRequestPerformedParams.first else { + expect(mockDiagnosticsTracker.trackedHttpRequestPerformedParams.value.count).toEventually(equal(1)) + guard let trackedParams = mockDiagnosticsTracker.trackedHttpRequestPerformedParams.value.first else { fail("Should have at least one call to tracked diagnostics") return } @@ -1652,8 +1652,8 @@ final class HTTPClientTests: BaseHTTPClientTests { // swiftlint:disable:next force_cast let mockDiagnosticsTracker = self.diagnosticsTracker as! MockDiagnosticsTracker - expect(mockDiagnosticsTracker.trackedHttpRequestPerformedParams.count).toEventually(equal(1)) - guard let trackedParams = mockDiagnosticsTracker.trackedHttpRequestPerformedParams.first else { + expect(mockDiagnosticsTracker.trackedHttpRequestPerformedParams.value.count).toEventually(equal(1)) + guard let trackedParams = mockDiagnosticsTracker.trackedHttpRequestPerformedParams.value.first else { fail("Should have at least one call to tracked diagnostics") return }