From 02f7a98d86fac58f574d4d25562398c25d3654c8 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Mon, 10 Oct 2022 14:46:32 -0700 Subject: [PATCH] Introduced `SDKTester` to help diagnose SDK configuration errors Finishes [CSDK-451]. This new small public API allows users to quickly figure out if everything in the SDK is correctly configured in a simple way: ```swift let tester = SDKTester.default do { try await tester.test() } catch { print(error) } ``` The specific underlying errors will provide information about what failed. We can continue growing this to check for more specific things, but for now it does 4 things: - Verify API connectivity: networking issues, firewalling, etc. - Verify API key is correct - Verify `Offerings` are configured correctly - Verify that all products in `Offerings` are configured correctly and found in `StoreKit` This new API is covered by: - API testers - Unit tests - Integration tests (both on `SK1` and `SK2`) - #1970 - #1971 - #1973 - #1974 - #1975 - #1976 --- RevenueCat.xcodeproj/project.pbxproj | 16 + .../DocCDocumentation.docc/Purchases.md | 1 + .../DocCDocumentation.docc/RevenueCat.md | 1 + Sources/Networking/Backend.swift | 14 + Sources/Purchasing/Purchases/Purchases.swift | 20 +- .../Purchasing/Purchases/PurchasesType.swift | 15 + Sources/Support/SDKTester.swift | 170 ++++++++ .../ObjCAPITester.xcodeproj/project.pbxproj | 6 + .../ObjCAPITester/RCSDKTesterAPI.h | 18 + .../ObjCAPITester/RCSDKTesterAPI.m | 20 + .../ObjCAPITester/ObjCAPITester/main.m | 3 + .../SwiftAPITester.xcodeproj/project.pbxproj | 4 + .../SwiftAPITester/SDKTesterAPI.swift | 35 ++ .../OtherIntegrationTests.swift | 25 ++ .../StoreKitIntegrationTests.swift | 5 + Tests/UnitTests/Misc/SDKTesterTests.swift | 133 ++++++ Tests/UnitTests/Mocks/MockPurchases.swift | 406 ++++++++++++++++++ 17 files changed, 888 insertions(+), 4 deletions(-) create mode 100644 Sources/Support/SDKTester.swift create mode 100644 Tests/APITesters/ObjCAPITester/ObjCAPITester/RCSDKTesterAPI.h create mode 100644 Tests/APITesters/ObjCAPITester/ObjCAPITester/RCSDKTesterAPI.m create mode 100644 Tests/APITesters/SwiftAPITester/SwiftAPITester/SDKTesterAPI.swift create mode 100644 Tests/BackendIntegrationTests/OtherIntegrationTests.swift create mode 100644 Tests/UnitTests/Misc/SDKTesterTests.swift create mode 100644 Tests/UnitTests/Mocks/MockPurchases.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 704c4eff3a..ba03b2364b 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -274,8 +274,12 @@ 5774F9BE2805E71100997128 /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 5774F9BD2805E71100997128 /* Fixtures */; }; 5774F9C12805EA3000997128 /* BaseHTTPResponseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5774F9C02805EA3000997128 /* BaseHTTPResponseTest.swift */; }; 5774F9C22805EA6900997128 /* CustomerInfoDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5774F9B92805E6E200997128 /* CustomerInfoDecodingTests.swift */; }; + 578C5F2C28DB82DD00A56F02 /* SDKTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578C5F2B28DB82DD00A56F02 /* SDKTester.swift */; }; 578FB10E27ADDA8000F70709 /* AvailabilityChecks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BE0263275942D500915B4C /* AvailabilityChecks.swift */; }; 579189B728F4747700BF4963 /* EitherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 579189B628F4747700BF4963 /* EitherTests.swift */; }; + 579189E928F47E8D00BF4963 /* SDKTesterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 579189E828F47E8D00BF4963 /* SDKTesterTests.swift */; }; + 579189EB28F47F0F00BF4963 /* MockPurchases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 579189EA28F47F0F00BF4963 /* MockPurchases.swift */; }; + 579189FD28F4966500BF4963 /* OtherIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 579189FC28F4966500BF4963 /* OtherIntegrationTests.swift */; }; 5791A1C82767FC9400C972AA /* ManageSubscriptionsHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5791A1C72767FC9400C972AA /* ManageSubscriptionsHelperTests.swift */; }; 5791CE80273F26A000E50C4B /* base64encoded_sandboxReceipt.txt in Resources */ = {isa = PBXBuildFile; fileRef = 5791CE7F273F26A000E50C4B /* base64encoded_sandboxReceipt.txt */; }; 579234E327F7788900B39C68 /* BaseBackendIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 579234E127F777EE00B39C68 /* BaseBackendIntegrationTests.swift */; }; @@ -791,7 +795,11 @@ 5774F9B92805E6E200997128 /* CustomerInfoDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerInfoDecodingTests.swift; sourceTree = ""; }; 5774F9BD2805E71100997128 /* Fixtures */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = folder; name = Fixtures; path = Tests/UnitTests/Networking/Responses/Fixtures; sourceTree = SOURCE_ROOT; }; 5774F9C02805EA3000997128 /* BaseHTTPResponseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseHTTPResponseTest.swift; sourceTree = ""; }; + 578C5F2B28DB82DD00A56F02 /* SDKTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKTester.swift; sourceTree = ""; }; 579189B628F4747700BF4963 /* EitherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EitherTests.swift; sourceTree = ""; }; + 579189E828F47E8D00BF4963 /* SDKTesterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKTesterTests.swift; sourceTree = ""; }; + 579189EA28F47F0F00BF4963 /* MockPurchases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPurchases.swift; sourceTree = ""; }; + 579189FC28F4966500BF4963 /* OtherIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherIntegrationTests.swift; sourceTree = ""; }; 5791A1C72767FC9400C972AA /* ManageSubscriptionsHelperTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManageSubscriptionsHelperTests.swift; sourceTree = ""; }; 5791CE7F273F26A000E50C4B /* base64encoded_sandboxReceipt.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = base64encoded_sandboxReceipt.txt; sourceTree = ""; }; 579234E127F777EE00B39C68 /* BaseBackendIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseBackendIntegrationTests.swift; sourceTree = ""; }; @@ -1397,6 +1405,7 @@ 37E35C9439E087F63ECC4F59 /* MockProductsManager.swift */, 37E35B08709090FBBFB16EBD /* MockProductsRequest.swift */, 37E35F783903362B65FB7AF3 /* MockProductsRequestFactory.swift */, + 579189EA28F47F0F00BF4963 /* MockPurchases.swift */, 351B515D26D44B9900BD2BD7 /* MockPurchasesDelegate.swift */, 351B515126D44AF000BD2BD7 /* MockReceiptFetcher.swift */, 37E354B13440508B46C9A530 /* MockReceiptParser.swift */, @@ -1425,6 +1434,7 @@ isa = PBXGroup; children = ( 579234E127F777EE00B39C68 /* BaseBackendIntegrationTests.swift */, + 579189FC28F4966500BF4963 /* OtherIntegrationTests.swift */, 2DE20B6E264087FB004C597D /* StoreKitIntegrationTests.swift */, 579234E427F779FE00B39C68 /* SubscriberAttributesManagerIntegrationTests.swift */, 2CD2C541278CE0E0005D1CC2 /* RevenueCat_IntegrationPurchaseTesterConfiguration.storekit */, @@ -1506,6 +1516,7 @@ 5766AA59283D4CAB00FA6091 /* IgnoreHashableTests.swift */, 57ACB12328174B9F000DCC9F /* CustomerInfo+TestExtensions.swift */, 57FDAABD28493A29009A48F1 /* SandboxEnvironmentDetectorTests.swift */, + 579189E828F47E8D00BF4963 /* SDKTesterTests.swift */, 57032ABE28C13CE4004FF47A /* StoreKit2SettingTests.swift */, ); path = Misc; @@ -1646,6 +1657,7 @@ children = ( A5F0104D2717B3150090732D /* BeginRefundRequestHelper.swift */, 35E840C5270FB47C00899AE2 /* ManageSubscriptionsHelper.swift */, + 578C5F2B28DB82DD00A56F02 /* SDKTester.swift */, ); path = Support; sourceTree = ""; @@ -2424,6 +2436,7 @@ 5751379527F4C4D80064AB2C /* Optional+Extensions.swift in Sources */, B3852FA026C1ED1F005384F8 /* IdentityManager.swift in Sources */, 9A65E03625918B0500DE00B0 /* ConfigureStrings.swift in Sources */, + 578C5F2C28DB82DD00A56F02 /* SDKTester.swift in Sources */, B34605C9279A6E380031CA74 /* GetIntroEligibilityOperation.swift in Sources */, 354895D6267BEDE3001DC5B1 /* ReservedSubscriberAttributes.swift in Sources */, 570FAF4B2864EC2300D3C769 /* NonSubscriptionTransaction.swift in Sources */, @@ -2676,9 +2689,11 @@ 5766AAE5283E9E9C00FA6091 /* PurchasesGetOfferingsTests.swift in Sources */, 2DDF41DA24F6F4DB005BC22D /* ReceiptParserTests.swift in Sources */, 2D1015E2275A67E40086173F /* SubscriptionPeriodTests.swift in Sources */, + 579189EB28F47F0F00BF4963 /* MockPurchases.swift in Sources */, 574A2EE9282C403800150D40 /* AnyDecodableTests.swift in Sources */, 351B519F26D4508A00BD2BD7 /* DeviceCacheTests.swift in Sources */, 2D22BF6826F3CC6D001AE2F9 /* XCTestCase+Extensions.swift in Sources */, + 579189E928F47E8D00BF4963 /* SDKTesterTests.swift in Sources */, 351B51B626D450E800BD2BD7 /* ReceiptFetcherTests.swift in Sources */, 5796A3C027D7D64500653165 /* ResultExtensionsTests.swift in Sources */, 351B51A726D450D400BD2BD7 /* SystemInfoTests.swift in Sources */, @@ -2771,6 +2786,7 @@ 579234E527F779FE00B39C68 /* SubscriberAttributesManagerIntegrationTests.swift in Sources */, 2D3BFAD226DEA46600370B11 /* MockProductsRequest.swift in Sources */, 2D1015DB275A4EAE0086173F /* AvailabilityChecks.swift in Sources */, + 579189FD28F4966500BF4963 /* OtherIntegrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/DocCDocumentation/DocCDocumentation.docc/Purchases.md b/Sources/DocCDocumentation/DocCDocumentation.docc/Purchases.md index a35968285c..8b9e01b613 100644 --- a/Sources/DocCDocumentation/DocCDocumentation.docc/Purchases.md +++ b/Sources/DocCDocumentation/DocCDocumentation.docc/Purchases.md @@ -24,6 +24,7 @@ Most features require configuring the SDK before using it. ### Configuring the SDK - ``Purchases/configure(withAPIKey:)`` - ``Purchases/configure(with:)-6oipy`` +- ``SDKTester`` ### Displaying Products - ``Purchases/offerings()`` diff --git a/Sources/DocCDocumentation/DocCDocumentation.docc/RevenueCat.md b/Sources/DocCDocumentation/DocCDocumentation.docc/RevenueCat.md index 5655907c81..86b33d2ff0 100644 --- a/Sources/DocCDocumentation/DocCDocumentation.docc/RevenueCat.md +++ b/Sources/DocCDocumentation/DocCDocumentation.docc/RevenueCat.md @@ -56,6 +56,7 @@ Or browse our iOS sample apps: ### Configuring the SDK - ``Purchases/configure(withAPIKey:)`` - ``Purchases/configure(with:)-6oipy`` +- ``SDKTester`` ### Displaying Products - ``Offerings`` diff --git a/Sources/Networking/Backend.swift b/Sources/Networking/Backend.swift index 49babf8586..fb4357af2e 100644 --- a/Sources/Networking/Backend.swift +++ b/Sources/Networking/Backend.swift @@ -125,6 +125,20 @@ class Backend { } +extension Backend { + + /// - Throws: `NetworkError` + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) + func healthRequest() async throws { + try await Async.call { completion in + self.internalAPI.healthRequest { error in + completion(.init(error)) + } + } + } + +} + // @unchecked because: // - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. extension Backend: @unchecked Sendable {} diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index c1a3d82815..dfb187fe6c 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -1054,14 +1054,28 @@ extension Purchases: @unchecked Sendable {} // MARK: Internal +extension Purchases: InternalPurchasesType { + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) + internal func healthRequest() async throws { + do { + try await self.backend.healthRequest() + } catch { + throw NewErrorUtils.purchasesError(withUntypedError: error) + } + } + +} + +/// Necessary because `ErrorUtils` inside of `Purchases` finds the obsoleted type. +private typealias NewErrorUtils = ErrorUtils + internal extension Purchases { var isStoreKit1Configured: Bool { return self.paymentQueueWrapper.sk1Wrapper != nil } - #if DEBUG - /// - Returns: the parsed `AppleReceipt` /// /// - Warning: this is only meant for integration tests, as a way to debug purchase failures. @@ -1072,8 +1086,6 @@ internal extension Purchases { return try receipt.map { try ReceiptParser.default.parse(from: $0) } } - #endif - /// - Parameter syncedAttribute: will be called for every attribute that is updated /// - Parameter completion: will be called once all attributes have completed syncing /// - Returns: the number of attributes that will be synced diff --git a/Sources/Purchasing/Purchases/PurchasesType.swift b/Sources/Purchasing/Purchases/PurchasesType.swift index 75456333b6..c90df8965e 100644 --- a/Sources/Purchasing/Purchases/PurchasesType.swift +++ b/Sources/Purchasing/Purchases/PurchasesType.swift @@ -941,3 +941,18 @@ public protocol PurchasesSwiftType: AnyObject { #endif } + +// MARK: - + +/// Interface for ``Purchases``'s internal-only methods. +internal protocol InternalPurchasesType: AnyObject { + + /// Performs an unauthenticated request to the API to verify connectivity. + /// - Throws: `PublicError` if request failed. + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) + func healthRequest() async throws + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) + func offerings(fetchPolicy: OfferingsManager.FetchPolicy) async throws -> Offerings + +} diff --git a/Sources/Support/SDKTester.swift b/Sources/Support/SDKTester.swift new file mode 100644 index 0000000000..a48007c6d9 --- /dev/null +++ b/Sources/Support/SDKTester.swift @@ -0,0 +1,170 @@ +// +// 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 +// +// SDKTester.swift +// +// Created by Nacho Soto on 9/21/22. + +import Foundation + +/// `SDKTester` allows you to ensure that the SDK is set up correctly by diagnosing configuration errors. +/// To run the test, simply call ``SDKTester/test()``. +/// +/// #### Example: +/// ```swift +/// let tester = SDKTester.default +/// do { +/// try await tester.test() +/// } catch { +/// print("SDKTester failed: \(error.localizedDescription)") +/// } +/// ``` +@objc(RCSDKTester) +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +public final class SDKTester: NSObject { + + typealias SDK = PurchasesType & InternalPurchasesType + + private let purchases: SDK + + init(purchases: SDK) { + self.purchases = purchases + } + + /// Default instance of `SDKTester`. + /// Note: you must call ``Purchases/configure(with:)-6oipy`` before using this. + @objc + public static let `default`: SDKTester = .init(purchases: Purchases.shared) +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +extension SDKTester { + + /// An error that represents a failing step in ``SDKTester`` + public enum Error: Swift.Error { + + /// Connection to the API failed + case failedConectingToAPI(Swift.Error) + + /// API key is invalid + case invalidAPIKey + + /// Fetching offerings failed due to the underlying error + case failedFetchingOfferings(Swift.Error) + + /// Any other not identifier error. You can check the undelying error for details. + case unknown(Swift.Error) + + } + +} + +// MARK: - Implementation + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +extension SDKTester { + + /// Perform tests to ensure SDK is configured correctly. + /// - `Throws`: ``SDKTester/Error`` if any step fails + @objc(testWithCompletion:) + public func test() async throws { + do { + try await self.unauthenticatedRequest() + try await self.authenticatedRequest() + try await self.offeringsRequest() + } catch let error as Error { + throw error + } catch let error { + // Catch every other error to ensure that we only throw `Error`s from here. + throw Error.unknown(error) + } + } + +} + +// MARK: - Private + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +private extension SDKTester { + + /// Makes a request to the backend, to verify connectivity, firewalls, or anything blocking network traffic. + func unauthenticatedRequest() async throws { + do { + try await self.purchases.healthRequest() + } catch { + throw Error.failedConectingToAPI(error) + } + } + + func authenticatedRequest() async throws { + do { + _ = try await self.purchases.customerInfo() + } catch let error as ErrorCode { + throw self.convert(error) + } catch { + throw Error.unknown(error) + } + } + + func offeringsRequest() async throws { + do { + _ = try await self.purchases.offerings(fetchPolicy: .failIfProductsAreMissing) + } catch { + throw Error.failedFetchingOfferings(error) + } + } + + func convert(_ error: ErrorCode) -> Error { + switch error { + case .unknownError: + return Error.unknown(error) + + case .offlineConnectionError: + return Error.failedConectingToAPI(error) + + case .invalidCredentialsError: + return Error.invalidAPIKey + + default: + return Error.unknown(error) + } + } + +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +extension SDKTester.Error: CustomNSError { + + // swiftlint:disable:next missing_docs + public var errorUserInfo: [String: Any] { + return [ + NSUnderlyingErrorKey: self.underlyingError as NSError? ?? NSNull(), + NSLocalizedDescriptionKey: self.localizedDescription + ] + } + + var localizedDescription: String { + switch self { + case let .unknown(error): return "Unknown error: \(error.localizedDescription)" + case let .failedConectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)" + case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)" + case .invalidAPIKey: return "API key is not valid" + } + } + + private var underlyingError: Swift.Error? { + switch self { + case let .unknown(error): return error + case let .failedConectingToAPI(error): return error + case let .failedFetchingOfferings(error): return error + case .invalidAPIKey: return nil + } + } + +} diff --git a/Tests/APITesters/ObjCAPITester/ObjCAPITester.xcodeproj/project.pbxproj b/Tests/APITesters/ObjCAPITester/ObjCAPITester.xcodeproj/project.pbxproj index 862ed8b92c..cb3f619b6b 100644 --- a/Tests/APITesters/ObjCAPITester/ObjCAPITester.xcodeproj/project.pbxproj +++ b/Tests/APITesters/ObjCAPITester/ObjCAPITester.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 575885A52748274E00CA2169 /* RevenueCat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 575885A32748274E00CA2169 /* RevenueCat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5758EE5227864A8500B3B703 /* RCStoreProductAPI.m in Sources */ = {isa = PBXBuildFile; fileRef = 5758EE5127864A8500B3B703 /* RCStoreProductAPI.m */; }; 5758EE5B2786548D00B3B703 /* RCStoreTransactionAPI.m in Sources */ = {isa = PBXBuildFile; fileRef = 5758EE5A2786548D00B3B703 /* RCStoreTransactionAPI.m */; }; + 57918A1628F4C58300BF4963 /* RCSDKTesterAPI.m in Sources */ = {isa = PBXBuildFile; fileRef = 57918A1528F4C58300BF4963 /* RCSDKTesterAPI.m */; }; 57DE807828074E59008D6C6F /* RCStorefrontAPI.m in Sources */ = {isa = PBXBuildFile; fileRef = 57DE807728074E59008D6C6F /* RCStorefrontAPI.m */; }; A513AD32272B328800E0C1BA /* RCRefundRequestStatusAPI.m in Sources */ = {isa = PBXBuildFile; fileRef = A513AD30272B327A00E0C1BA /* RCRefundRequestStatusAPI.m */; }; B32554452825E74000DA62EA /* RCConfigurationAPI.m in Sources */ = {isa = PBXBuildFile; fileRef = B32554442825E74000DA62EA /* RCConfigurationAPI.m */; }; @@ -65,6 +66,8 @@ 5758EE5127864A8500B3B703 /* RCStoreProductAPI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCStoreProductAPI.m; sourceTree = ""; }; 5758EE592786548D00B3B703 /* RCStoreTransactionAPI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCStoreTransactionAPI.h; sourceTree = ""; }; 5758EE5A2786548D00B3B703 /* RCStoreTransactionAPI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCStoreTransactionAPI.m; sourceTree = ""; }; + 57918A1428F4C58300BF4963 /* RCSDKTesterAPI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCSDKTesterAPI.h; sourceTree = ""; }; + 57918A1528F4C58300BF4963 /* RCSDKTesterAPI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCSDKTesterAPI.m; sourceTree = ""; }; 57DE807628074E59008D6C6F /* RCStorefrontAPI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCStorefrontAPI.h; sourceTree = ""; }; 57DE807728074E59008D6C6F /* RCStorefrontAPI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCStorefrontAPI.m; sourceTree = ""; }; A513AD2F272B327A00E0C1BA /* RCRefundRequestStatusAPI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCRefundRequestStatusAPI.h; sourceTree = ""; }; @@ -164,6 +167,8 @@ A5D614EE26EBE84F007DDB75 /* RCPurchasesErrorCodeAPI.m */, A513AD2F272B327A00E0C1BA /* RCRefundRequestStatusAPI.h */, A513AD30272B327A00E0C1BA /* RCRefundRequestStatusAPI.m */, + 57918A1428F4C58300BF4963 /* RCSDKTesterAPI.h */, + 57918A1528F4C58300BF4963 /* RCSDKTesterAPI.m */, 57DE807628074E59008D6C6F /* RCStorefrontAPI.h */, 57DE807728074E59008D6C6F /* RCStorefrontAPI.m */, 5758EE5027864A8500B3B703 /* RCStoreProductAPI.h */, @@ -279,6 +284,7 @@ 2DD7790E270E23870079CBD4 /* RCIntroEligibilityAPI.m in Sources */, 5738F42D278674710096D623 /* RCSubscriptionPeriodAPI.m in Sources */, 2DD77916270E23870079CBD4 /* RCTransactionAPI.m in Sources */, + 57918A1628F4C58300BF4963 /* RCSDKTesterAPI.m in Sources */, 570FAF502864ECB000D3C769 /* RCNonSubscriptionTransactionAPI.m in Sources */, 2DD7790F270E23870079CBD4 /* RCPurchasesAPI.m in Sources */, B378153D2857A750000A7B93 /* RCAttributionAPI.m in Sources */, diff --git a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCSDKTesterAPI.h b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCSDKTesterAPI.h new file mode 100644 index 0000000000..c4efe15a74 --- /dev/null +++ b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCSDKTesterAPI.h @@ -0,0 +1,18 @@ +// +// RCSDKTesterAPI.h +// ObjCAPITester +// +// Created by Nacho Soto on 10/10/22. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RCSDKTesterAPI : NSObject + ++ (void)checkAPI; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCSDKTesterAPI.m b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCSDKTesterAPI.m new file mode 100644 index 0000000000..6801668658 --- /dev/null +++ b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCSDKTesterAPI.m @@ -0,0 +1,20 @@ +// +// RCSDKTesterAPI.m +// ObjCAPITester +// +// Created by Nacho Soto on 10/10/22. +// + +#import "RCSDKTesterAPI.h" + +@import RevenueCat; + +@implementation RCSDKTesterAPI + ++ (void)checkAPI { + RCSDKTester *tester = [RCSDKTester default]; + + [tester testWithCompletion:^(NSError * _Nullable error) {}]; +} + +@end diff --git a/Tests/APITesters/ObjCAPITester/ObjCAPITester/main.m b/Tests/APITesters/ObjCAPITester/ObjCAPITester/main.m index 6190cf7254..ef3f6662cf 100644 --- a/Tests/APITesters/ObjCAPITester/ObjCAPITester/main.m +++ b/Tests/APITesters/ObjCAPITester/ObjCAPITester/main.m @@ -21,6 +21,7 @@ #import "RCPurchasesAPI.h" #import "RCPurchasesErrorCodeAPI.h" #import "RCRefundRequestStatusAPI.h" +#import "RCSDKTesterAPI.h" #import "RCStorefrontAPI.h" #import "RCStoreProductAPI.h" #import "RCStoreProductDiscountAPI.h" @@ -64,6 +65,8 @@ int main(int argc, const char * argv[]) { [RCRefundRequestStatusAPI checkEnums]; + [RCSDKTesterAPI checkAPI]; + [RCStorefrontAPI checkAPI]; [RCStoreProductAPI checkAPI]; diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester.xcodeproj/project.pbxproj b/Tests/APITesters/SwiftAPITester/SwiftAPITester.xcodeproj/project.pbxproj index faa40dd8d7..b11bb817ee 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester.xcodeproj/project.pbxproj +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 5758859D2748272A00CA2169 /* RevenueCat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5758859B2748272A00CA2169 /* RevenueCat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5758EE4F2786493400B3B703 /* StoreProductAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5758EE4E2786493300B3B703 /* StoreProductAPI.swift */; }; 5758EE582786542200B3B703 /* StoreTransactionAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5758EE572786542200B3B703 /* StoreTransactionAPI.swift */; }; + 57918A1328F4C49500BF4963 /* SDKTesterAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57918A1228F4C49500BF4963 /* SDKTesterAPI.swift */; }; 57DE807528074D9C008D6C6F /* StorefrontAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DE807428074D9C008D6C6F /* StorefrontAPI.swift */; }; A513AD35272B4C0100E0C1BA /* RefundRequestStatusAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A513AD33272B4C0100E0C1BA /* RefundRequestStatusAPI.swift */; }; B32554422825E5EA00DA62EA /* ConfigurationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32554412825E5EA00DA62EA /* ConfigurationAPI.swift */; }; @@ -58,6 +59,7 @@ 5758859B2748272A00CA2169 /* RevenueCat.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RevenueCat.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5758EE4E2786493300B3B703 /* StoreProductAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductAPI.swift; sourceTree = ""; }; 5758EE572786542200B3B703 /* StoreTransactionAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreTransactionAPI.swift; sourceTree = ""; }; + 57918A1228F4C49500BF4963 /* SDKTesterAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKTesterAPI.swift; sourceTree = ""; }; 57DE807428074D9C008D6C6F /* StorefrontAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorefrontAPI.swift; sourceTree = ""; }; A513AD33272B4C0100E0C1BA /* RefundRequestStatusAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefundRequestStatusAPI.swift; sourceTree = ""; }; A5D614C226EBE7EA007DDB75 /* ErrorCodesAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorCodesAPI.swift; sourceTree = ""; }; @@ -136,6 +138,7 @@ B3A4C833280DE72600D4AE17 /* PromotionalOfferAPI.swift */, A5D614CE26EBE7EA007DDB75 /* PurchasesAPI.swift */, A513AD33272B4C0100E0C1BA /* RefundRequestStatusAPI.swift */, + 57918A1228F4C49500BF4963 /* SDKTesterAPI.swift */, 57DE807428074D9C008D6C6F /* StorefrontAPI.swift */, 5758EE4E2786493300B3B703 /* StoreProductAPI.swift */, 5738F40B27866DD00096D623 /* StoreProductDiscountAPI.swift */, @@ -223,6 +226,7 @@ 5738F42127866F8F0096D623 /* main.swift in Sources */, A513AD35272B4C0100E0C1BA /* RefundRequestStatusAPI.swift in Sources */, 2DD778E8270E23460079CBD4 /* PackageAPI.swift in Sources */, + 57918A1328F4C49500BF4963 /* SDKTesterAPI.swift in Sources */, 570FAF562864EE1D00D3C769 /* NonSubscriptionTransactionAPI.swift in Sources */, 57DE807528074D9C008D6C6F /* StorefrontAPI.swift in Sources */, 2DD778E6270E23460079CBD4 /* PurchasesAPI.swift in Sources */, diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/SDKTesterAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/SDKTesterAPI.swift new file mode 100644 index 0000000000..70d7fdd1f9 --- /dev/null +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/SDKTesterAPI.swift @@ -0,0 +1,35 @@ +// +// SDKTesterAPI.swift +// SwiftAPITester +// +// Created by Nacho Soto on 10/10/22. +// + +import Foundation +import RevenueCat + +func checkSDKTester() { + let _: SDKTester = .default +} + +private func checkSDKTesterAsync(_ tester: SDKTester) async { + _ = try? await tester.test() +} + +func checkSDKTesterErrors(_ error: SDKTester.Error) { + switch error { + case let .failedConectingToAPI(error): + print(error) + + case .invalidAPIKey: + break + + case let .failedFetchingOfferings(error): + print(error) + + case let .unknown(error): + print(error) + + @unknown default: break + } +} diff --git a/Tests/BackendIntegrationTests/OtherIntegrationTests.swift b/Tests/BackendIntegrationTests/OtherIntegrationTests.swift new file mode 100644 index 0000000000..e0f99e5168 --- /dev/null +++ b/Tests/BackendIntegrationTests/OtherIntegrationTests.swift @@ -0,0 +1,25 @@ +// +// 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 +// +// OtherIntegrationTests.swift +// +// Created by Nacho Soto on 10/10/22. + +import Nimble +@testable import RevenueCat +import StoreKitTest +import XCTest + +class OtherIntegrationTests: BaseBackendIntegrationTests { + + func testHealthRequest() async throws { + try await Purchases.shared.healthRequest() + } + +} diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index 59198085f0..f7156ea725 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -55,6 +55,11 @@ class StoreKit1IntegrationTests: BaseBackendIntegrationTests { expect(Purchases.shared.isSandbox) == true } + func testSDKTester() async throws { + let tester = SDKTester.default + try await tester.test() + } + func testCanGetOfferings() async throws { let receivedOfferings = try await Purchases.shared.offerings() expect(receivedOfferings.all).toNot(beEmpty()) diff --git a/Tests/UnitTests/Misc/SDKTesterTests.swift b/Tests/UnitTests/Misc/SDKTesterTests.swift new file mode 100644 index 0000000000..e0bceb4a6f --- /dev/null +++ b/Tests/UnitTests/Misc/SDKTesterTests.swift @@ -0,0 +1,133 @@ +// +// 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 +// +// SDKTesterTests.swift +// +// Created by Nacho Soto on 10/10/22. + +import Nimble +import XCTest + +@testable import RevenueCat + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +class SDKTesterTests: TestCase { + + private var purchases: MockPurchases! + private var tester: SDKTester! + + override func setUp() async throws { + try super.setUpWithError() + + try AvailabilityChecks.iOS13APIAvailableOrSkipTest() + + self.purchases = .init() + self.tester = .init(purchases: self.purchases) + + self.purchases.mockedHealthRequestResponse = .success(()) + self.purchases.mockedCustomerInfoResponse = .success( + try CustomerInfo(data: [ + "request_date": "2019-08-16T10:30:42Z", + "subscriber": [ + "first_seen": "2019-07-17T00:05:54Z", + "original_app_user_id": "", + "subscriptions": [:], + "other_purchases": [:] + ]]) + ) + self.purchases.mockedOfferingsResponse = .success(.init(offerings: [:], currentOfferingID: nil)) + } + + func testFailingHealthRequest() async throws { + let error = ErrorUtils.offlineConnectionError().asPublicError + self.purchases.mockedHealthRequestResponse = .failure(error) + + do { + try await self.tester.test() + fail("Expected error") + } catch let SDKTester.Error.failedConectingToAPI(underlyingError) { + expect(underlyingError).to(matchError(error)) + } catch { + fail("Unexpected error: \(error)") + } + } + + func testFailingAuthenticatedRequest() async throws { + let error = ErrorUtils + .backendError(withBackendCode: .invalidAPIKey, backendMessage: "Invalid API key") + .asPublicError + self.purchases.mockedCustomerInfoResponse = .failure(error) + + do { + try await self.tester.test() + fail("Expected error") + } catch SDKTester.Error.invalidAPIKey { + // Expected error + } catch { + fail("Unexpected error: \(error)") + } + } + + func testFailingOfferingsRequest() async throws { + let error = OfferingsManager.Error.missingProducts(identifiers: ["a"]).asPublicError + self.purchases.mockedOfferingsResponse = .failure(error) + + do { + try await self.tester.test() + fail("Expected error") + } catch let SDKTester.Error.failedFetchingOfferings(offeringsError) { + expect(offeringsError).to(matchError(error)) + expect(self.purchases.invokedGetOfferingsParameters) == .failIfProductsAreMissing + } catch { + fail("Unexpected error: \(error)") + } + } + + func testSuccessfulTest() async throws { + do { + try await self.tester.test() + } catch { + fail("Unexpected error: \(error)") + } + } + + // MARK: - Errors + + func testUnknownError() { + let underlyingError = ErrorUtils.missingReceiptFileError() + let error = SDKTester.Error.unknown(underlyingError) + + expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSError).to(matchError(underlyingError)) + expect(error.localizedDescription) == "Unknown error: \(underlyingError.localizedDescription)" + } + + func testInvalidAPIKey() { + let error = SDKTester.Error.invalidAPIKey + + expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSNull).toNot(beNil()) + expect(error.localizedDescription) == "API key is not valid" + } + + func testFailedConnectingToAPI() { + let underlyingError = OfferingsManager.Error.missingProducts(identifiers: ["a"]).asPublicError + let error = SDKTester.Error.failedFetchingOfferings(underlyingError) + + expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSError).to(matchError(underlyingError)) + expect(error.localizedDescription) == "Failed fetching offerings: \(underlyingError.localizedDescription)" + } + + func testFetchingOfferingsError() { + let underlyingError = ErrorUtils.missingReceiptFileError() + let error = SDKTester.Error.unknown(underlyingError) + + expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSError).to(matchError(underlyingError)) + expect(error.localizedDescription) == "Unknown error: \(underlyingError.localizedDescription)" + } + +} diff --git a/Tests/UnitTests/Mocks/MockPurchases.swift b/Tests/UnitTests/Mocks/MockPurchases.swift new file mode 100644 index 0000000000..7a88e63988 --- /dev/null +++ b/Tests/UnitTests/Mocks/MockPurchases.swift @@ -0,0 +1,406 @@ +// +// 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 +// +// MockPurchases.swift +// +// Created by Nacho Soto on 10/10/22. + +@testable import RevenueCat + +final class MockPurchases { + + @_disfavoredOverload + fileprivate func unimplemented() { + let _: Void = self.unimplemented() + } + + fileprivate func unimplemented() -> T { + fatalError("Mocked method not implemented") + } + + // MARK: - + + var invokedGetCustomerInfo = false + var mockedCustomerInfoResponse: Result = .failure( + ErrorUtils.unknownError().asPublicError + ) + + var invokedGetOfferings = false + var invokedGetOfferingsParameters: OfferingsManager.FetchPolicy? + var mockedOfferingsResponse: Result = .failure( + ErrorUtils.unknownError().asPublicError + ) + + var invokedHealthRequest = false + var mockedHealthRequestResponse: Result = .success(()) + +} + +extension MockPurchases: InternalPurchasesType { + + func healthRequest() async throws { + return try self.mockedHealthRequestResponse.get() + } + +} + +extension MockPurchases: PurchasesType { + + func getCustomerInfo(completion: @escaping ((CustomerInfo?, PublicError?) -> Void)) { + self.invokedGetCustomerInfo = true + completion(self.mockedCustomerInfoResponse.value, self.mockedCustomerInfoResponse.error) + } + + func getCustomerInfo( + fetchPolicy: CacheFetchPolicy, + completion: @escaping (CustomerInfo?, PublicError?) -> Void + ) { + self.getCustomerInfo(completion: completion) + } + + func customerInfo() async throws -> CustomerInfo { + self.invokedGetCustomerInfo = true + return try self.mockedCustomerInfoResponse.get() + } + + func getOfferings(completion: @escaping ((Offerings?, PublicError?) -> Void)) { + self.invokedGetOfferings = true + completion(self.mockedOfferingsResponse.value, self.mockedOfferingsResponse.error) + } + + func offerings() async throws -> Offerings { + return try await self.offerings(fetchPolicy: .default) + } + + func offerings(fetchPolicy: OfferingsManager.FetchPolicy) async throws -> Offerings { + self.invokedGetOfferings = true + self.invokedGetOfferingsParameters = fetchPolicy + return try self.mockedOfferingsResponse.get() + } + + // MARK: - Unimplemented + + var appUserID: String { + self.unimplemented() + } + + var isAnonymous: Bool { + self.unimplemented() + } + + var finishTransactions: Bool { + get { self.unimplemented() } + // swiftlint:disable:next unused_setter_value + set { self.unimplemented() } + } + + var delegate: PurchasesDelegate? { + get { self.unimplemented() } + // swiftlint:disable:next unused_setter_value + set { self.unimplemented() } + } + + func logIn( + _ appUserID: String, + completion: @escaping (CustomerInfo?, + Bool, + PublicError? + ) -> Void) { + self.unimplemented() + } + + func logIn( + _ appUserID: String + ) async throws -> (customerInfo: CustomerInfo, created: Bool) { + self.unimplemented() + } + + func logOut(completion: ((CustomerInfo?, PublicError?) -> Void)?) { + self.unimplemented() + } + + func logOut() async throws -> CustomerInfo { + self.unimplemented() + } + + func customerInfo(fetchPolicy: CacheFetchPolicy) async throws -> CustomerInfo { + self.unimplemented() + } + + func getProducts(_ productIdentifiers: [String], completion: @escaping ([StoreProduct]) -> Void) { + self.unimplemented() + } + + func products(_ productIdentifiers: [String]) async -> [StoreProduct] { + self.unimplemented() + } + + func purchase(product: StoreProduct, completion: @escaping PurchaseCompletedBlock) { + self.unimplemented() + } + + func purchase(product: StoreProduct) async throws -> PurchaseResultData { + self.unimplemented() + } + + func purchase(package: Package, completion: @escaping PurchaseCompletedBlock) { + self.unimplemented() + } + + func purchase(package: Package) async throws -> PurchaseResultData { + self.unimplemented() + } + + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer, + completion: @escaping PurchaseCompletedBlock + ) { + self.unimplemented() + } + + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer + ) async throws -> PurchaseResultData { + self.unimplemented() + } + + func purchase( + package: Package, + promotionalOffer: PromotionalOffer, + completion: @escaping PurchaseCompletedBlock + ) { + self.unimplemented() + } + + func purchase( + package: Package, + promotionalOffer: PromotionalOffer + ) async throws -> PurchaseResultData { + self.unimplemented() + } + + func restorePurchases(completion: ((CustomerInfo?, PublicError?) -> Void)?) { + self.unimplemented() + } + + func restorePurchases() async throws -> CustomerInfo { + self.unimplemented() + } + + func syncPurchases(completion: ((CustomerInfo?, PublicError?) -> Void)?) { + self.unimplemented() + } + + func syncPurchases() async throws -> CustomerInfo { + self.unimplemented() + } + + func checkTrialOrIntroDiscountEligibility( + productIdentifiers: [String], + completion receiveEligibility: @escaping ([String: IntroEligibility]) -> Void + ) { + self.unimplemented() + } + + func checkTrialOrIntroDiscountEligibility( + productIdentifiers: [String] + ) async -> [String: IntroEligibility] { + self.unimplemented() + } + + func checkTrialOrIntroDiscountEligibility( + product: StoreProduct, + completion: @escaping (IntroEligibilityStatus) -> Void + ) { + self.unimplemented() + } + + func checkTrialOrIntroDiscountEligibility( + product: StoreProduct + ) async -> IntroEligibilityStatus { + self.unimplemented() + } + + func getPromotionalOffer( + forProductDiscount discount: StoreProductDiscount, + product: StoreProduct, + completion: @escaping ((PromotionalOffer?, PublicError?) -> Void) + ) { + self.unimplemented() + } + + func promotionalOffer( + forProductDiscount discount: StoreProductDiscount, + product: StoreProduct + ) async throws -> PromotionalOffer { + self.unimplemented() + } + + func eligiblePromotionalOffers(forProduct product: StoreProduct) async -> [PromotionalOffer] { + self.unimplemented() + } + + func invalidateCustomerInfoCache() { + self.unimplemented() + } + + func beginRefundRequest(forProduct productID: String) async throws -> RefundRequestStatus { + self.unimplemented() + } + + func beginRefundRequest(forEntitlement entitlementID: String) async throws -> RefundRequestStatus { + self.unimplemented() + } + + func beginRefundRequestForActiveEntitlement() async throws -> RefundRequestStatus { + self.unimplemented() + } + + func presentCodeRedemptionSheet() { + self.unimplemented() + } + + func showPriceConsentIfNeeded() { + self.unimplemented() + } + + func showManageSubscriptions(completion: @escaping (PublicError?) -> Void) { + self.unimplemented() + } + + func showManageSubscriptions() async throws { + self.unimplemented() + } + + var attribution: Attribution { + self.unimplemented() + } + + func setAttributes(_ attributes: [String: String]) { + self.unimplemented() + } + + var allowSharingAppStoreAccount: Bool { + get { self.unimplemented() } + // swiftlint:disable:next unused_setter_value + set { self.unimplemented() } + } + + func setEmail(_ email: String?) { + self.unimplemented() + } + + func setPhoneNumber(_ phoneNumber: String?) { + self.unimplemented() + } + + func setDisplayName(_ displayName: String?) { + self.unimplemented() + } + + func setPushToken(_ pushToken: Data?) { + self.unimplemented() + } + + func setPushTokenString(_ pushToken: String?) { + self.unimplemented() + } + + func setAdjustID(_ adjustID: String?) { + self.unimplemented() + } + + func setAppsflyerID(_ appsflyerID: String?) { + self.unimplemented() + } + + func setFBAnonymousID(_ fbAnonymousID: String?) { + self.unimplemented() + } + + func setMparticleID(_ mparticleID: String?) { + self.unimplemented() + } + + func setOnesignalID(_ onesignalID: String?) { + self.unimplemented() + } + + func setMediaSource(_ mediaSource: String?) { + self.unimplemented() + } + + func setCampaign(_ campaign: String?) { + self.unimplemented() + } + + func setAdGroup(_ adGroup: String?) { + self.unimplemented() + } + + func setAd(_ value: String?) { + self.unimplemented() + } + + func setKeyword(_ keyword: String?) { + self.unimplemented() + } + + func setCreative(_ creative: String?) { + self.unimplemented() + } + + func setCleverTapID(_ cleverTapID: String?) { + self.unimplemented() + } + + func setMixpanelDistinctID(_ mixpanelDistinctID: String?) { + self.unimplemented() + } + + func setFirebaseAppInstanceID(_ firebaseAppInstanceID: String?) { + self.unimplemented() + } + + func collectDeviceIdentifiers() { + self.unimplemented() + } + +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +extension MockPurchases: PurchasesSwiftType { + + var customerInfoStream: AsyncStream { + self.unimplemented() + } + + func beginRefundRequest( + forProduct productID: String, + completion: @escaping (Result) -> Void + ) { + self.unimplemented() + } + + func beginRefundRequest( + forEntitlement entitlementID: String, + completion: @escaping (Result) -> Void + ) { + self.unimplemented() + } + + func beginRefundRequestForActiveEntitlement( + completion: @escaping (Result) -> Void + ) { + self.unimplemented() + } + +}