diff --git a/Core/DailyPixel.swift b/Core/DailyPixel.swift index db0b5f0b33..98d0f037e9 100644 --- a/Core/DailyPixel.swift +++ b/Core/DailyPixel.swift @@ -43,7 +43,7 @@ public final class DailyPixel { } - private static let storage: UserDefaults = UserDefaults(suiteName: Constant.dailyPixelStorageIdentifier)! + public static let storage: UserDefaults = UserDefaults(suiteName: Constant.dailyPixelStorageIdentifier)! /// Sends a given Pixel once per day. /// This is useful in situations where pixels receive spikes in volume, as the daily pixel can be used to determine how many users are actually affected. diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 020d779a7d..0fcf5016ef 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -68,6 +68,8 @@ 1E4DCF4A27B6A38000961E25 /* DownloadListRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DCF4927B6A38000961E25 /* DownloadListRepresentable.swift */; }; 1E4DCF4C27B6A4CB00961E25 /* URLFileExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DCF4B27B6A4CB00961E25 /* URLFileExtension.swift */; }; 1E4DCF4E27B6A69600961E25 /* DownloadsListHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DCF4D27B6A69600961E25 /* DownloadsListHostingController.swift */; }; + 1E4E6C552C775B400059C0FA /* Subscription.storekit in Resources */ = {isa = PBXBuildFile; fileRef = D664C7952B289AA000CBFA76 /* Subscription.storekit */; }; + 1E4E6C572C78B8540059C0FA /* StorePurchaseManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4E6C562C78B8540059C0FA /* StorePurchaseManagerTests.swift */; }; 1E4F4A5A297193DE00625985 /* MainViewController+CookiesManaged.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4F4A59297193DE00625985 /* MainViewController+CookiesManaged.swift */; }; 1E4FAA6427D8DFB900ADC5B3 /* OngoingDownloadRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4FAA6327D8DFB900ADC5B3 /* OngoingDownloadRowViewModel.swift */; }; 1E4FAA6627D8DFC800ADC5B3 /* CompleteDownloadRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4FAA6527D8DFC800ADC5B3 /* CompleteDownloadRowViewModel.swift */; }; @@ -100,6 +102,7 @@ 1E9529A12C4E748B006E80D4 /* UINavigationControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9529A02C4E748B006E80D4 /* UINavigationControllerExtension.swift */; }; 1EA51376286596A000493C6A /* PrivacyIconLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA51375286596A000493C6A /* PrivacyIconLogic.swift */; }; 1EA513782866039400493C6A /* TrackerAnimationLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA513772866039400493C6A /* TrackerAnimationLogic.swift */; }; + 1EAABE712C99FC75003F5137 /* SubscriptionFeatureAvailabilityMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E25D5312C92126B004400F0 /* SubscriptionFeatureAvailabilityMock.swift */; }; 1EC458462948932500CB2B13 /* UIHostingControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EC458452948932500CB2B13 /* UIHostingControllerExtension.swift */; }; 1EDE39D22705D4A200C99C72 /* FileSizeDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDE39D12705D4A100C99C72 /* FileSizeDebugViewController.swift */; }; 1EE411F12857C3640003FE64 /* TrackerAnimationImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE411F02857C3640003FE64 /* TrackerAnimationImageProvider.swift */; }; @@ -1355,11 +1358,13 @@ 1E1D8B6929953CE300C96994 /* autoconsent-test-page-banner.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = "autoconsent-test-page-banner.html"; sourceTree = ""; }; 1E24295D293F57FA00584836 /* LottieView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieView.swift; sourceTree = ""; }; 1E24295F293F585300584836 /* cookie-icon-animated-40-light.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "cookie-icon-animated-40-light.json"; sourceTree = ""; }; + 1E25D5312C92126B004400F0 /* SubscriptionFeatureAvailabilityMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionFeatureAvailabilityMock.swift; sourceTree = ""; }; 1E4DCF4527B6A33600961E25 /* DownloadsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsListViewModel.swift; sourceTree = ""; }; 1E4DCF4727B6A35400961E25 /* DownloadsListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsListModel.swift; sourceTree = ""; }; 1E4DCF4927B6A38000961E25 /* DownloadListRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadListRepresentable.swift; sourceTree = ""; }; 1E4DCF4B27B6A4CB00961E25 /* URLFileExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLFileExtension.swift; sourceTree = ""; }; 1E4DCF4D27B6A69600961E25 /* DownloadsListHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsListHostingController.swift; sourceTree = ""; }; + 1E4E6C562C78B8540059C0FA /* StorePurchaseManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePurchaseManagerTests.swift; sourceTree = ""; }; 1E4F4A59297193DE00625985 /* MainViewController+CookiesManaged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainViewController+CookiesManaged.swift"; sourceTree = ""; }; 1E4FAA6327D8DFB900ADC5B3 /* OngoingDownloadRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OngoingDownloadRowViewModel.swift; sourceTree = ""; }; 1E4FAA6527D8DFC800ADC5B3 /* CompleteDownloadRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompleteDownloadRowViewModel.swift; sourceTree = ""; }; @@ -3312,6 +3317,14 @@ name = Autoconsent; sourceTree = ""; }; + 1E25D5302C921246004400F0 /* Mocks */ = { + isa = PBXGroup; + children = ( + 1E25D5312C92126B004400F0 /* SubscriptionFeatureAvailabilityMock.swift */, + ); + name = Mocks; + sourceTree = ""; + }; 1E4DCF4227B6A29D00961E25 /* View */ = { isa = PBXGroup; children = ( @@ -5296,7 +5309,6 @@ D60170BB2BA32DD6001911B5 /* Subscription.swift */, 1E53508E2C7C9A1F00818DAA /* DefaultSubscriptionManager+AccountManagerKeychainAccessDelegate.swift */, D6D95CE42B6DA3F200960317 /* AsyncHeadlessWebview */, - D664C7952B289AA000CBFA76 /* Subscription.storekit */, D664C7932B289AA000CBFA76 /* ViewModel */, D664C7AC2B289AA000CBFA76 /* Views */, D664C7B02B289AA000CBFA76 /* UserScripts */, @@ -6082,10 +6094,13 @@ F1BDDBFC2C340D9C00459306 /* Subscription */ = { isa = PBXGroup; children = ( + 1E25D5302C921246004400F0 /* Mocks */, + D664C7952B289AA000CBFA76 /* Subscription.storekit */, BDE219E92C457B46005D5884 /* PrivacyProDataReporterTests.swift */, F1BDDBF92C340D9C00459306 /* SubscriptionContainerViewModelTests.swift */, F1BDDBFA2C340D9C00459306 /* SubscriptionFlowViewModelTests.swift */, F1BDDBFB2C340D9C00459306 /* SubscriptionPagesUseSubscriptionFeatureTests.swift */, + 1E4E6C562C78B8540059C0FA /* StorePurchaseManagerTests.swift */, ); path = Subscription; sourceTree = ""; @@ -7018,6 +7033,7 @@ files = ( EA39B7E2268A1A35000C62CD /* privacy-reference-tests in Resources */, 8524092D2C77EF7A00CB28FC /* mobile_segments_test_cases.json in Resources */, + 1E4E6C552C775B400059C0FA /* Subscription.storekit in Resources */, F17843E91F36226700390DCD /* MockFiles in Resources */, 8572298A2BBEF0C800E2E802 /* AppRatingPrompt_v1 in Resources */, ); @@ -7899,6 +7915,7 @@ 6FF915822B88E07A0042AC87 /* AdAttributionFetcherTests.swift in Sources */, 9F4CC51B2C48C0C7006A96EB /* MockTabDelegate.swift in Sources */, 85E065C22C73ADE600D73E2A /* UsageSegmentationTests.swift in Sources */, + 1E4E6C572C78B8540059C0FA /* StorePurchaseManagerTests.swift in Sources */, 1E722729292EB24D003B5F53 /* AppSettingsMock.swift in Sources */, 8536A1C8209AF2410050739E /* MockVariantManager.swift in Sources */, C1B7B53428944EFA0098FD6A /* CoreDataTestUtilities.swift in Sources */, @@ -7949,6 +7966,7 @@ BDFF03262BA3DA4900F324C9 /* NetworkProtectionFeatureVisibilityTests.swift in Sources */, 0283A2042C6E572F00508FBD /* BrokenSitePromptLimiterTests.swift in Sources */, D62EC3BA2C246A7000FC9D04 /* YoutublePlayerNavigationHandlerTests.swift in Sources */, + 1EAABE712C99FC75003F5137 /* SubscriptionFeatureAvailabilityMock.swift in Sources */, 8341D807212D5E8D000514C2 /* HashExtensionTest.swift in Sources */, C1D21E2F293A599C006E5A05 /* AutofillLoginSessionTests.swift in Sources */, 85D2187924BF6B8B004373D2 /* FaviconSourcesProviderTests.swift in Sources */, @@ -10909,7 +10927,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 196.1.0; + version = 196.2.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d61b026cd2..cf5b1aae12 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "f7083a3c74a4aa1f6a0f4ab65265eb2f422a2cf0", - "version" : "196.1.0" + "revision" : "32a2ec64385543ccfbaaafbfe9545543a2c06aac", + "version" : "196.2.0" } }, { @@ -138,7 +138,7 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", "version" : "1.4.0" diff --git a/DuckDuckGoTests/NetworkProtectionFeatureVisibilityTests.swift b/DuckDuckGoTests/NetworkProtectionFeatureVisibilityTests.swift index 1ccbd0f91a..04b77fcfa8 100644 --- a/DuckDuckGoTests/NetworkProtectionFeatureVisibilityTests.swift +++ b/DuckDuckGoTests/NetworkProtectionFeatureVisibilityTests.swift @@ -64,7 +64,7 @@ struct NetworkProtectionFeatureVisibilityMocks: NetworkProtectionFeatureVisibili init(with options: Options) { self.options = options - let subscriptionAppGroup = Bundle.main.appGroup(bundle: .subs) + let subscriptionAppGroup = "NetworkProtectionFeatureVisibilityTests" let subscriptionUserDefaults = UserDefaults(suiteName: subscriptionAppGroup)! let subscriptionEnvironment = DefaultSubscriptionManager.getSavedOrDefaultEnvironment(userDefaults: subscriptionUserDefaults) let entitlementsCache = UserDefaultsCache<[Entitlement]>(userDefaults: subscriptionUserDefaults, diff --git a/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift b/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift new file mode 100644 index 0000000000..d8f38e823f --- /dev/null +++ b/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift @@ -0,0 +1,190 @@ +// +// StorePurchaseManagerTests.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities +import StoreKitTest + +final class StorePurchaseManagerTests: XCTestCase { + + private struct Constants { + static let externalID = UUID().uuidString + static let monthlySubscriptionID = "ios.subscription.1month" + static let yearlySubscriptionID = "ios.subscription.1year" + } + + var session: SKTestSession! + var storePurchaseManager: StorePurchaseManager! + + override func setUpWithError() throws { + let path = Bundle.main.url(forResource: "Subscription", withExtension: "storekit") + + session = try SKTestSession(contentsOf: path!) + session.resetToDefaultState() + session.disableDialogs = true + session.clearTransactions() + + storePurchaseManager = DefaultStorePurchaseManager() + } + + override func tearDownWithError() throws { + storePurchaseManager = nil + session = nil + } + + func testSubscriptionOptionsWhenNoCachedProducts() async throws { + // When + let subscriptionOptions = await storePurchaseManager.subscriptionOptions() + + // Then + XCTAssertNil(subscriptionOptions) + XCTAssertFalse(storePurchaseManager.areProductsAvailable) + } + + func testSubscriptionOptionsWhenAvailableProductsWereUpdated() async throws { + // Given + await storePurchaseManager.updateAvailableProducts() + + // When + guard let subscriptionOptions = await storePurchaseManager.subscriptionOptions() else { + XCTFail("Expected subscription options") + return + } + + // Then + XCTAssertEqual(subscriptionOptions.options.count, 2) + XCTAssertEqual(subscriptionOptions.features.count, SubscriptionFeatureName.allCases.count) + XCTAssertTrue(storePurchaseManager.areProductsAvailable) + + let optionIDs = subscriptionOptions.options.map { $0.id } + XCTAssertTrue(optionIDs.contains(Constants.monthlySubscriptionID)) + XCTAssertTrue(optionIDs.contains(Constants.yearlySubscriptionID)) + } + + func testHasActiveSubscriptionIsFalseWithoutPurchase() async throws { + // When + let hasActiveSubscription = await storePurchaseManager.hasActiveSubscription() + + // Then + XCTAssertFalse(hasActiveSubscription) + } + + func testPurchaseSubscription() async throws { + // Given + await storePurchaseManager.updateAvailableProducts() + + XCTAssertEqual(storePurchaseManager.purchasedProductIDs, []) + + // When + let result = await storePurchaseManager.purchaseSubscription(with: Constants.yearlySubscriptionID, externalID: Constants.externalID) + + // Then + switch result { + case .success: + XCTAssertTrue(storePurchaseManager.purchaseQueue.isEmpty) + XCTAssertEqual(storePurchaseManager.purchasedProductIDs, [Constants.yearlySubscriptionID]) + + let transactions = await StoreKitHelpers.currentEntitlements() + XCTAssertEqual(transactions.count, 1) + XCTAssertEqual(transactions.first!.appAccountToken?.uuidString, Constants.externalID) + + let hasActiveSubscription = await storePurchaseManager.hasActiveSubscription() + XCTAssertTrue(hasActiveSubscription) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testPurchaseSubscriptionFailureWithoutValidProductID() async throws { + // Given + await storePurchaseManager.updateAvailableProducts() + + // When + let result = await storePurchaseManager.purchaseSubscription(with: "", externalID: Constants.externalID) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + XCTAssertEqual(error, StorePurchaseManagerError.productNotFound) + } + } + + func testPurchaseSubscriptionFailureWithoutValidUUID() async throws { + // Given + await storePurchaseManager.updateAvailableProducts() + + let invalidUUID = "a" + XCTAssertNil(UUID(uuidString: invalidUUID)) + + // When + let result = await storePurchaseManager.purchaseSubscription(with: Constants.yearlySubscriptionID, externalID: invalidUUID) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + XCTAssertEqual(error, StorePurchaseManagerError.externalIDisNotAValidUUID) + } + } + + @available(iOS 17.0, *) + func testPurchaseSubscriptionFailure() async throws { + // Given + try? await session.setSimulatedError(SKTestFailures.Purchase.purchase(.productUnavailable), + forAPI: StoreKitPurchaseAPI.purchase) + + await storePurchaseManager.updateAvailableProducts() + + // When + let result = await storePurchaseManager.purchaseSubscription(with: Constants.yearlySubscriptionID, externalID: Constants.externalID) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + XCTAssertEqual(error, StorePurchaseManagerError.purchaseFailed) + } + } +} + +private final class StoreKitHelpers { + + static func currentEntitlements() async -> [Transaction] { + return await Transaction.currentEntitlements.compactMap { result in + try? checkVerified(result) + }.reduce(into: [], { $0.append($1) }) + } + + static func checkVerified(_ result: VerificationResult) throws -> T { + // Check whether the JWS passes StoreKit verification. + switch result { + case .unverified: + // StoreKit parses the JWS, but it fails verification. + throw StoreError.failedVerification + case .verified(let safe): + // The result is verified. Return the unwrapped value. + return safe + } + } +} diff --git a/DuckDuckGo/Subscription/Subscription.storekit b/DuckDuckGoTests/Subscription/Subscription.storekit similarity index 100% rename from DuckDuckGo/Subscription/Subscription.storekit rename to DuckDuckGoTests/Subscription/Subscription.storekit diff --git a/DuckDuckGoTests/Subscription/SubscriptionFeatureAvailabilityMock.swift b/DuckDuckGoTests/Subscription/SubscriptionFeatureAvailabilityMock.swift new file mode 100644 index 0000000000..70d007616f --- /dev/null +++ b/DuckDuckGoTests/Subscription/SubscriptionFeatureAvailabilityMock.swift @@ -0,0 +1,34 @@ +// +// SubscriptionFeatureAvailabilityMock.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +@testable import BrowserServicesKit + +public final class SubscriptionFeatureAvailabilityMock: SubscriptionFeatureAvailability { + public var isFeatureAvailable: Bool + public var isSubscriptionPurchaseAllowed: Bool + public var usesUnifiedFeedbackForm: Bool + + public init(isFeatureAvailable: Bool, isSubscriptionPurchaseAllowed: Bool, usesUnifiedFeedbackForm: Bool) { + self.isFeatureAvailable = isFeatureAvailable + self.isSubscriptionPurchaseAllowed = isSubscriptionPurchaseAllowed + self.usesUnifiedFeedbackForm = usesUnifiedFeedbackForm + } + +} diff --git a/DuckDuckGoTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift b/DuckDuckGoTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift index 0cb08f6fac..a3b80166f4 100644 --- a/DuckDuckGoTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift +++ b/DuckDuckGoTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift @@ -19,28 +19,1043 @@ import XCTest @testable import DuckDuckGo +@testable import Core @testable import Subscription import SubscriptionTestingUtilities +import Common +import WebKit +import BrowserServicesKit +import OHHTTPStubs +import OHHTTPStubsSwift +import os.log final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { + private struct Constants { + static let userDefaultsSuiteName = "SubscriptionPagesUseSubscriptionFeatureTests" + + static let authToken = UUID().uuidString + static let accessToken = UUID().uuidString + static let externalID = UUID().uuidString + + static let email = "dax@duck.com" + + static let entitlements = [Entitlement(product: .dataBrokerProtection), + Entitlement(product: .identityTheftRestoration), + Entitlement(product: .networkProtection)] + + static let mostRecentTransactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" + + static let subscriptionOptions = SubscriptionOptions(platform: SubscriptionPlatformName.ios.rawValue, + options: [ + SubscriptionOption(id: "1", + cost: SubscriptionOptionCost(displayPrice: "9 USD", recurrence: "monthly")), + SubscriptionOption(id: "2", + cost: SubscriptionOptionCost(displayPrice: "99 USD", recurrence: "yearly")) + ], + features: [ + SubscriptionFeature(name: "vpn"), + SubscriptionFeature(name: "personal-information-removal"), + SubscriptionFeature(name: "identity-theft-restoration") + ]) + + static let validateTokenResponse = ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, + entitlements: Constants.entitlements, + externalID: Constants.externalID)) + + static let mockParams: [String: String] = [:] + @MainActor static let mockScriptMessage = MockWKScriptMessage(name: "", body: "", webView: WKWebView() ) + + static let invalidTokenError = APIServiceError.serverError(statusCode: 401, error: "invalid_token") + } + + var userDefaults: UserDefaults! + + var accountStorage: AccountKeychainStorageMock! + var accessTokenStorage: SubscriptionTokenKeychainStorageMock! + var entitlementsCache: UserDefaultsCache<[Entitlement]>! + + var subscriptionService: SubscriptionEndpointServiceMock! + var authService: AuthEndpointServiceMock! + + var storePurchaseManager: StorePurchaseManagerMock! + var subscriptionEnvironment: SubscriptionEnvironment! + + var appStorePurchaseFlow: AppStorePurchaseFlow! + var appStoreRestoreFlow: AppStoreRestoreFlow! + var appStoreAccountManagementFlow: AppStoreAccountManagementFlow! + + var accountManager: AccountManager! + var subscriptionManager: SubscriptionManager! + + var feature: SubscriptionPagesUseSubscriptionFeature! + + var pixelsFired: [String] = [] + override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + // Pixels + Pixel.isDryRun = false + stub(condition: isHost("improving.duckduckgo.com")) { request -> HTTPStubsResponse in + if let path = request.url?.path { + let pixelName = path.dropping(prefix: "/t/") + .dropping(suffix: "_ios_phone") + .dropping(suffix: "_ios_tablet") + self.pixelsFired.append(pixelName) + } + + return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) + } + + // Reset all daily pixel storage + [Pixel.storage, DailyPixel.storage, UniquePixel.storage].forEach { storage in + storage.dictionaryRepresentation().keys.forEach(storage.removeObject(forKey:)) + } + + // Mocks + subscriptionService = SubscriptionEndpointServiceMock() + authService = AuthEndpointServiceMock() + + storePurchaseManager = StorePurchaseManagerMock() + subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, + purchasePlatform: .appStore) + accountStorage = AccountKeychainStorageMock() + accessTokenStorage = SubscriptionTokenKeychainStorageMock() + + userDefaults = UserDefaults(suiteName: Constants.userDefaultsSuiteName)! + userDefaults.removePersistentDomain(forName: Constants.userDefaultsSuiteName) + + entitlementsCache = UserDefaultsCache<[Entitlement]>(userDefaults: userDefaults, + key: UserDefaultsCacheKey.subscriptionEntitlements, + settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) + + // Real AccountManager + accountManager = DefaultAccountManager(storage: accountStorage, + accessTokenStorage: accessTokenStorage, + entitlementsCache: entitlementsCache, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService) + + // Real Flows + appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, + storePurchaseManager: storePurchaseManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService) + + appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionEndpointService: subscriptionService, + storePurchaseManager: storePurchaseManager, + accountManager: accountManager, + appStoreRestoreFlow: appStoreRestoreFlow, + authEndpointService: authService) + + appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(authEndpointService: authService, + storePurchaseManager: storePurchaseManager, + accountManager: accountManager) + // Real SubscriptionManager + subscriptionManager = DefaultSubscriptionManager(storePurchaseManager: storePurchaseManager, + accountManager: accountManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService, + subscriptionEnvironment: subscriptionEnvironment) + + feature = SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, + subscriptionAttributionOrigin: nil, + appStorePurchaseFlow: appStorePurchaseFlow, + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow) } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - let appStorePurchaseFlow = AppStorePurchaseFlowMock(purchaseSubscriptionResult: .success("TransactionJWS"), - completeSubscriptionPurchaseResult: .success(PurchaseUpdate(type: "t", token: "t"))) - let appStoreAccountManagementFlow = AppStoreAccountManagementFlowMock(refreshAuthTokenIfNeededResult: .success("Something")) - let feature = SubscriptionPagesUseSubscriptionFeature(subscriptionManager: SubscriptionMockFactory.subscriptionManager, - subscriptionAttributionOrigin: "???", - appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: SubscriptionMockFactory.appStoreRestoreFlow, - appStoreAccountManagementFlow: appStoreAccountManagementFlow) - // To be implemented + Pixel.isDryRun = true + pixelsFired.removeAll() + HTTPStubs.removeAllStubs() + + AppDependencyProvider.shared = AppDependencyProvider() + + subscriptionService = nil + authService = nil + storePurchaseManager = nil + subscriptionEnvironment = nil + + userDefaults = nil + + accountStorage = nil + accessTokenStorage = nil + + entitlementsCache.reset() + entitlementsCache = nil + + accountManager = nil + + // Real Flows + appStorePurchaseFlow = nil + appStoreRestoreFlow = nil + appStoreAccountManagementFlow = nil + + subscriptionManager = nil + + feature = nil + } + + // MARK: - Tests for getSubscription + + func testGetSubscriptionSuccessRefreshingAuthToken() async throws { + // Given + ensureUserAuthenticatedState() + + let newAuthToken = UUID().uuidString + + authService.validateTokenResult = .failure(Constants.invalidTokenError) + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + authService.storeLoginResult = .success(StoreLoginResponse(authToken: newAuthToken, + email: Constants.email, + externalID: Constants.externalID, + id: 1, status: "authenticated")) + + // When + let result = await feature.getSubscription(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + let resultDictionary = try XCTUnwrap(result as? [String: String]) + + XCTAssertEqual(resultDictionary[SubscriptionPagesUseSubscriptionFeature.Constants.token], newAuthToken) + XCTAssertEqual(accountManager.authToken, newAuthToken) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + + func testGetSubscriptionSuccessWithoutRefreshingAuthToken() async throws { + // Given + ensureUserAuthenticatedState() + + authService.validateTokenResult = .success(Constants.validateTokenResponse) + + // When + let result = await feature.getSubscription(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + let resultDictionary = try XCTUnwrap(result as? [String: String]) + + XCTAssertEqual(resultDictionary[SubscriptionPagesUseSubscriptionFeature.Constants.token], Constants.authToken) + XCTAssertEqual(accountManager.authToken, Constants.authToken) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + + func testGetSubscriptionSuccessErrorWhenUnauthenticated() async throws { + // Given + ensureUserUnauthenticatedState() + + authService.validateTokenResult = .failure(Constants.invalidTokenError) + storePurchaseManager.mostRecentTransactionResult = nil + + // When + let result = await feature.getSubscription(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + let resultDictionary = try XCTUnwrap(result as? [String: String]) + + XCTAssertEqual(resultDictionary[SubscriptionPagesUseSubscriptionFeature.Constants.token], SubscriptionPagesUseSubscriptionFeature.Constants.empty) + XCTAssertFalse(accountManager.isUserAuthenticated) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + + // MARK: - Tests for getSubscriptionOptions + + func testGetSubscriptionOptionsSuccess() async throws { + // Given + storePurchaseManager.subscriptionOptionsResult = Constants.subscriptionOptions + + // When + let result = await feature.getSubscriptionOptions(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + let subscriptionOptionsResult = try XCTUnwrap(result as? SubscriptionOptions) + + XCTAssertEqual(subscriptionOptionsResult, Constants.subscriptionOptions) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + + func testGetSubscriptionOptionsReturnsEmptyOptionsWhenNoSubscriptionOptions() async throws { + // Given + storePurchaseManager.subscriptionOptionsResult = nil + + // When + let result = await feature.getSubscriptionOptions(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + let subscriptionOptionsResult = try XCTUnwrap(result as? SubscriptionOptions) + XCTAssertEqual(subscriptionOptionsResult, SubscriptionOptions.empty) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .failedToGetSubscriptionOptions) + + await XCTAssertPrivacyPixelsFired([]) + } + + func testGetSubscriptionOptionsReturnsEmptyOptionsWhenPurchaseNotAllowed() async throws { + // Given + let mockDependencyProvider = MockDependencyProvider() + mockDependencyProvider.subscriptionFeatureAvailability = SubscriptionFeatureAvailabilityMock(isFeatureAvailable: true, + isSubscriptionPurchaseAllowed: false, + usesUnifiedFeedbackForm: true) + AppDependencyProvider.shared = mockDependencyProvider + + storePurchaseManager.subscriptionOptionsResult = Constants.subscriptionOptions + + // When + let result = await feature.getSubscriptionOptions(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + let subscriptionOptionsResult = try XCTUnwrap(result as? SubscriptionOptions) + XCTAssertEqual(subscriptionOptionsResult, SubscriptionOptions.empty) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + + // MARK: - Tests for subscriptionSelected + + func testSubscriptionSelectedSuccessWhenPurchasingFirstTime() async throws { + // Given + ensureUserUnauthenticatedState() + + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.hasActiveSubscriptionResult = false + storePurchaseManager.mostRecentTransactionResult = nil + + authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, + externalID: Constants.externalID, + status: "created")) + authService.getAccessTokenResult = .success(AccessTokenResponse(accessToken: Constants.accessToken)) + authService.validateTokenResult = .success(Constants.validateTokenResponse) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.mostRecentTransactionJWS) + subscriptionService.confirmPurchaseResult = .success(ConfirmPurchaseResponse(email: Constants.email, + entitlements: Constants.entitlements, + subscription: SubscriptionMockFactory.subscription)) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c", + Pixel.Event.privacyProPurchaseSuccess.name + "_d", + Pixel.Event.privacyProPurchaseSuccess.name + "_c", + Pixel.Event.privacyProSubscriptionActivated.name, + Pixel.Event.privacyProSuccessfulSubscriptionAttribution.name]) + } + + func testSubscriptionSelectedSuccessWhenRepurchasingForExpiredAppleSubscription() async throws { + // Given + ensureUserAuthenticatedState() + + XCTAssertTrue(accountManager.isUserAuthenticated) + + storePurchaseManager.hasActiveSubscriptionResult = false + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.expiredSubscription) + + authService.storeLoginResult = .success(StoreLoginResponse(authToken: Constants.authToken, + email: Constants.email, + externalID: Constants.externalID, + id: 1, + status: "authenticated")) + authService.getAccessTokenResult = .success(AccessTokenResponse(accessToken: Constants.accessToken)) + authService.validateTokenResult = .success(Constants.validateTokenResponse) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.mostRecentTransactionJWS) + subscriptionService.confirmPurchaseResult = .success(ConfirmPurchaseResponse(email: Constants.email, + entitlements: Constants.entitlements, + subscription: SubscriptionMockFactory.subscription)) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertFalse(authService.createAccountCalled) + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c", + Pixel.Event.privacyProPurchaseSuccess.name + "_d", + Pixel.Event.privacyProPurchaseSuccess.name + "_c", + Pixel.Event.privacyProSubscriptionActivated.name, + Pixel.Event.privacyProSuccessfulSubscriptionAttribution.name]) + } + + func testSubscriptionSelectedSuccessWhenRepurchasingForExpiredStripeSubscription() async throws { + // Given + ensureUserAuthenticatedState() + + XCTAssertTrue(accountManager.isUserAuthenticated) + + storePurchaseManager.hasActiveSubscriptionResult = false + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.expiredStripeSubscription) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.mostRecentTransactionJWS) + subscriptionService.confirmPurchaseResult = .success(ConfirmPurchaseResponse(email: Constants.email, + entitlements: Constants.entitlements, + subscription: SubscriptionMockFactory.subscription)) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertFalse(authService.createAccountCalled) + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c", + Pixel.Event.privacyProPurchaseSuccess.name + "_d", + Pixel.Event.privacyProPurchaseSuccess.name + "_c", + Pixel.Event.privacyProSubscriptionActivated.name, + Pixel.Event.privacyProSuccessfulSubscriptionAttribution.name]) + } + + func testSubscriptionSelectedErrorWhenPurchasingWhenHavingActiveSubscription() async throws { + // Given + ensureUserAuthenticatedState() + + storePurchaseManager.hasActiveSubscriptionResult = true + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertFalse(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .hasActiveSubscription) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c", + Pixel.Event.privacyProRestoreAfterPurchaseAttempt.name]) + } + + func testSubscriptionSelectedErrorWhenPurchasingWhenUnauthenticatedAndHavingActiveSubscriptionOnAppleID() async throws { + // Given + ensureUserUnauthenticatedState() + + storePurchaseManager.hasActiveSubscriptionResult = true + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertFalse(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .hasActiveSubscription) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c", + Pixel.Event.privacyProRestoreAfterPurchaseAttempt.name]) + } + + func testSubscriptionSelectedErrorWhenUnauthenticatedAndAccountCreationFails() async throws { + // Given + ensureUserUnauthenticatedState() + + storePurchaseManager.hasActiveSubscriptionResult = false + storePurchaseManager.mostRecentTransactionResult = nil + + authService.createAccountResult = .failure(Constants.invalidTokenError) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertFalse(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .accountCreationFailed) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c"]) + } + + func testSubscriptionSelectedErrorWhenPurchaseCancelledByUser() async throws { + // Given + ensureUserAuthenticatedState() + + storePurchaseManager.hasActiveSubscriptionResult = false + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.expiredStripeSubscription) + storePurchaseManager.purchaseSubscriptionResult = .failure(StorePurchaseManagerError.purchaseCancelledByUser) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .cancelledByUser) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c"]) + } + + func testSubscriptionSelectedErrorWhenProductNotFound() async throws { + // Given + ensureUserAuthenticatedState() + + storePurchaseManager.hasActiveSubscriptionResult = false + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.expiredStripeSubscription) + storePurchaseManager.purchaseSubscriptionResult = .failure(StorePurchaseManagerError.productNotFound) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .purchaseFailed) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c"]) + } + + func testSubscriptionSelectedErrorWhenExternalIDIsNotValidUUID() async throws { + // Given + ensureUserAuthenticatedState() + + storePurchaseManager.hasActiveSubscriptionResult = false + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.expiredStripeSubscription) + storePurchaseManager.purchaseSubscriptionResult = .failure(StorePurchaseManagerError.externalIDisNotAValidUUID) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .purchaseFailed) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c"]) + } + + func testSubscriptionSelectedErrorWhenPurchaseFailed() async throws { + // Given + ensureUserAuthenticatedState() + + storePurchaseManager.hasActiveSubscriptionResult = false + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.expiredStripeSubscription) + storePurchaseManager.purchaseSubscriptionResult = .failure(StorePurchaseManagerError.purchaseFailed) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .purchaseFailed) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c"]) + } + + func testSubscriptionSelectedErrorWhenTransactionCannotBeVerified() async throws { + // Given + ensureUserAuthenticatedState() + + storePurchaseManager.hasActiveSubscriptionResult = false + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.expiredStripeSubscription) + storePurchaseManager.purchaseSubscriptionResult = .failure(StorePurchaseManagerError.transactionCannotBeVerified) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .purchaseFailed) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c"]) + } + + func testSubscriptionSelectedErrorWhenTransactionPendingAuthentication() async throws { + // Given + ensureUserAuthenticatedState() + + storePurchaseManager.hasActiveSubscriptionResult = false + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.expiredStripeSubscription) + storePurchaseManager.purchaseSubscriptionResult = .failure(StorePurchaseManagerError.transactionPendingAuthentication) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .purchaseFailed) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c"]) + } + + func testSubscriptionSelectedErrorDueToUnknownPurchaseError() async throws { + // Given + ensureUserAuthenticatedState() + + storePurchaseManager.hasActiveSubscriptionResult = false + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.expiredStripeSubscription) + storePurchaseManager.purchaseSubscriptionResult = .failure(StorePurchaseManagerError.unknownError) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .purchaseFailed) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProPurchaseAttempt.name + "_d", + Pixel.Event.privacyProPurchaseAttempt.name + "_c"]) + } + + // MARK: - Tests for setSubscription + + func testSetSubscriptionSuccess() async throws { + // Given + ensureUserUnauthenticatedState() + + authService.getAccessTokenResult = .success(.init(accessToken: Constants.accessToken)) + authService.validateTokenResult = .success(Constants.validateTokenResponse) + + let onSetSubscriptionCalled = expectation(description: "onSetSubscription") + feature.onSetSubscription = { + onSetSubscriptionCalled.fulfill() + } + + // When + let setSubscriptionParams = ["token": Constants.authToken] + let result = await feature.setSubscription(params: setSubscriptionParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertEqual(accountManager.authToken, Constants.authToken) + XCTAssertEqual(accountManager.accessToken, Constants.accessToken) + XCTAssertEqual(accountManager.email, Constants.email) + XCTAssertEqual(accountManager.externalID, Constants.externalID) + + await fulfillment(of: [onSetSubscriptionCalled], timeout: 0.5) + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + + func testSetSubscriptionErrorWhenFailedToExchangeToken() async throws { + // Given + ensureUserUnauthenticatedState() + + authService.getAccessTokenResult = .failure(Constants.invalidTokenError) + + let onSetSubscriptionCalled = expectation(description: "onSetSubscription") + onSetSubscriptionCalled.isInverted = true + feature.onSetSubscription = { + onSetSubscriptionCalled.fulfill() + } + + // When + let setSubscriptionParams = ["token": Constants.authToken] + let result = await feature.setSubscription(params: setSubscriptionParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertNil(accountManager.authToken) + XCTAssertFalse(accountManager.isUserAuthenticated) + + await fulfillment(of: [onSetSubscriptionCalled], timeout: 0.5) + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .failedToSetSubscription) + + await XCTAssertPrivacyPixelsFired([]) + } + + func testSetSubscriptionErrorWhenFailedToFetchAccountDetails() async throws { + // Given + ensureUserUnauthenticatedState() + + authService.getAccessTokenResult = .success(.init(accessToken: Constants.accessToken)) + authService.validateTokenResult = .failure(Constants.invalidTokenError) + + let onSetSubscriptionCalled = expectation(description: "onSetSubscription") + onSetSubscriptionCalled.isInverted = true + feature.onSetSubscription = { + onSetSubscriptionCalled.fulfill() + } + + // When + let setSubscriptionParams = ["token": Constants.authToken] + let result = await feature.setSubscription(params: setSubscriptionParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertNil(accountManager.authToken) + XCTAssertFalse(accountManager.isUserAuthenticated) + + await fulfillment(of: [onSetSubscriptionCalled], timeout: 0.5) + XCTAssertNil(result) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, .failedToSetSubscription) + + await XCTAssertPrivacyPixelsFired([]) + } + + // MARK: - Tests for activateSubscription + + func testActivateSubscriptionTokenSuccess() async throws { + // Given + ensureUserAuthenticatedState() + + let onActivateSubscriptionCalled = expectation(description: "onActivateSubscription") + feature.onActivateSubscription = { + onActivateSubscriptionCalled.fulfill() + } + + // When + let result = await feature.activateSubscription(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + await fulfillment(of: [onActivateSubscriptionCalled], timeout: 0.5) + XCTAssertNil(result) + + await XCTAssertPrivacyPixelsFired([Pixel.Event.privacyProRestorePurchaseOfferPageEntry.name]) + } + + // MARK: - Tests for featureSelected + + func testFeatureSelectedSuccess() async throws { + // Given + ensureUserAuthenticatedState() + + let onFeatureSelectedCalled = expectation(description: "onFeatureSelected") + feature.onFeatureSelected = { selection in + onFeatureSelectedCalled.fulfill() + XCTAssertEqual(selection, SubscriptionFeatureSelection.itr) + } + + // When + let featureSelectionParams = ["feature": SubscriptionFeatureName.itr] + let result = await feature.featureSelected(params: featureSelectionParams, original: Constants.mockScriptMessage) + + // Then + await fulfillment(of: [onFeatureSelectedCalled], timeout: 0.5) + XCTAssertNil(result) + + await XCTAssertPrivacyPixelsFired([]) + } + + // MARK: - Tests for backToSettings + + func testBackToSettingsSuccess() async throws { + // Given + ensureUserAuthenticatedState() + accountStorage.email = nil + + XCTAssertNil(accountManager.email) + + let onBackToSettingsCalled = expectation(description: "onBackToSettings") + feature.onBackToSettings = { + onBackToSettingsCalled.fulfill() + } + + authService.validateTokenResult = .success(Constants.validateTokenResponse) + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.subscription) + + // When + let result = await feature.backToSettings(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + await fulfillment(of: [onBackToSettingsCalled], timeout: 0.5) + + XCTAssertEqual(accountManager.email, Constants.email) + XCTAssertNil(result) + + await XCTAssertPrivacyPixelsFired([]) + } + + func testBackToSettingsErrorOnFetchingAccountDetails() async throws { + // Given + ensureUserAuthenticatedState() + + let onBackToSettingsCalled = expectation(description: "onBackToSettings") + onBackToSettingsCalled.isInverted = true + feature.onBackToSettings = { + onBackToSettingsCalled.fulfill() + } + + authService.validateTokenResult = .failure(Constants.invalidTokenError) + + // When + let result = await feature.backToSettings(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + await fulfillment(of: [onBackToSettingsCalled], timeout: 0.5) + + XCTAssertEqual(feature.transactionError, .generalError) + XCTAssertNil(result) + + await XCTAssertPrivacyPixelsFired([]) + } + + // MARK: - Tests for getAccessToken + func testGetAccessTokenSuccess() async throws { + // Given + ensureUserAuthenticatedState() + + // When + let result = try await feature.getAccessToken(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + let resultDictionary = try XCTUnwrap(result as? [String: String]) + XCTAssertEqual(resultDictionary[SubscriptionPagesUseSubscriptionFeature.Constants.token], Constants.accessToken) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + + func testGetAccessTokenEmptyOnMissingToken() async throws { + // Given + ensureUserUnauthenticatedState() + XCTAssertNil(accountManager.accessToken) + + // When + let result = try await feature.getAccessToken(params: Constants.mockParams, original: Constants.mockScriptMessage) + + // Then + let resultDictionary = try XCTUnwrap(result as? [String: String]) + XCTAssertEqual(resultDictionary, [String: String]()) + + await XCTAssertPrivacyPixelsFired([]) + } + + // MARK: - Tests for restoreAccountFromAppStorePurchase + + func testRestoreAccountFromAppStorePurchaseSuccess() async throws { + // Given + ensureUserUnauthenticatedState() + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + authService.storeLoginResult = .success(StoreLoginResponse(authToken: Constants.authToken, + email: Constants.email, + externalID: Constants.externalID, + id: 1, status: "authenticated")) + authService.getAccessTokenResult = .success(AccessTokenResponse(accessToken: Constants.accessToken)) + authService.validateTokenResult = .success(Constants.validateTokenResponse) + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.subscription) + + // When + try await feature.restoreAccountFromAppStorePurchase() + + // Then + XCTAssertTrue(accountManager.isUserAuthenticated) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + + func testRestoreAccountFromAppStorePurchaseErrorDueToExpiredSubscription() async throws { + // Given + ensureUserUnauthenticatedState() + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + authService.storeLoginResult = .success(StoreLoginResponse(authToken: Constants.authToken, + email: Constants.email, + externalID: Constants.externalID, + id: 1, status: "authenticated")) + authService.getAccessTokenResult = .success(AccessTokenResponse(accessToken: Constants.accessToken)) + authService.validateTokenResult = .success(Constants.validateTokenResponse) + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.expiredSubscription) + + + do { + // When + try await feature.restoreAccountFromAppStorePurchase() + XCTFail("Unexpected success") + } catch let error { + // Then + guard let error = error as? SubscriptionPagesUseSubscriptionFeature.UseSubscriptionError else { + XCTFail("Unexpected error type") + return + } + + XCTAssertEqual(error, .subscriptionExpired) + XCTAssertFalse(accountManager.isUserAuthenticated) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + } + + func testRestoreAccountFromAppStorePurchaseErrorDueToNoTransaction() async throws { + // Given + ensureUserUnauthenticatedState() + + storePurchaseManager.mostRecentTransactionResult = nil + + do { + // When + try await feature.restoreAccountFromAppStorePurchase() + XCTFail("Unexpected success") + } catch let error { + // Then + guard let error = error as? SubscriptionPagesUseSubscriptionFeature.UseSubscriptionError else { + XCTFail("Unexpected error type") + return + } + + XCTAssertEqual(error, .subscriptionNotFound) + XCTAssertFalse(accountManager.isUserAuthenticated) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + } + + func testRestoreAccountFromAppStorePurchaseErrorDueToOtherError() async throws { + // Given + ensureUserUnauthenticatedState() + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + authService.storeLoginResult = .failure(Constants.invalidTokenError) + + do { + // When + try await feature.restoreAccountFromAppStorePurchase() + XCTFail("Unexpected success") + } catch let error { + // Then + guard let error = error as? SubscriptionPagesUseSubscriptionFeature.UseSubscriptionError else { + XCTFail("Unexpected error type") + return + } + + XCTAssertEqual(error, .failedToRestorePastPurchase) + XCTAssertFalse(accountManager.isUserAuthenticated) + + XCTAssertEqual(feature.transactionStatus, .idle) + XCTAssertEqual(feature.transactionError, nil) + + await XCTAssertPrivacyPixelsFired([]) + } + } +} + +extension SubscriptionPagesUseSubscriptionFeatureTests { + + func ensureUserAuthenticatedState() { + accountStorage.authToken = Constants.authToken + accountStorage.email = Constants.email + accountStorage.externalID = Constants.externalID + accessTokenStorage.accessToken = Constants.accessToken + } + + func ensureUserUnauthenticatedState() { + try? accessTokenStorage.removeAccessToken() + try? accountStorage.clearAuthenticationState() + } + + public func XCTAssertPrivacyPixelsFired(_ pixels: [String], file: StaticString = #file, line: UInt = #line) async { + try? await Task.sleep(seconds: 0.1) + + let pixelsFired = Set(pixelsFired) + let expectedPixels = Set(pixels) + + // Assert expected pixels were fired + XCTAssertTrue(expectedPixels.isSubset(of: pixelsFired), + "Expected Privacy Pro pixels were not fired: \(expectedPixels.subtracting(pixelsFired))", + file: file, + line: line) + + // Assert no other Privacy Pro pixels were fired except the expected + let privacyProPixelPrefix = "m_privacy-pro" + let otherPixels = pixelsFired.subtracting(expectedPixels) + let otherPrivacyProPixels = otherPixels.filter { $0.hasPrefix(privacyProPixelPrefix) } + XCTAssertTrue(otherPrivacyProPixels.isEmpty, + "Unexpected Privacy Pro pixels fired: \(otherPrivacyProPixels)", + file: file, + line: line) } }