diff --git a/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift b/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift index b22ee8eac2..a46bb583ed 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift @@ -331,7 +331,7 @@ final class DataBrokerProtectionDebugMenu: NSMenu { } @objc private func forceBrokerJSONFilesUpdate() { - if let updater = DataBrokerProtectionBrokerUpdater.provide() { + if let updater = DefaultDataBrokerProtectionBrokerUpdater.provide() { updater.updateBrokers() } } diff --git a/DuckDuckGo/DBP/DataBrokerProtectionLoginItemScheduler.swift b/DuckDuckGo/DBP/DataBrokerProtectionLoginItemScheduler.swift index 50b75e9fac..02bd9767be 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionLoginItemScheduler.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionLoginItemScheduler.swift @@ -76,10 +76,6 @@ extension DataBrokerProtectionLoginItemScheduler: DataBrokerProtectionScheduler ipcScheduler.optOutAllBrokers(showWebView: showWebView, completion: completion) } - func runAllOperations(showWebView: Bool) { - ipcScheduler.runAllOperations(showWebView: showWebView) - } - func runQueuedOperations(showWebView: Bool, completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) { ipcScheduler.runQueuedOperations(showWebView: showWebView, completion: completion) diff --git a/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift b/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift index 2f91fe97a0..dfdfd2ba19 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift @@ -76,7 +76,6 @@ public class DataBrokerProtectionPixelsHandler: EventMapping Void) { ipcClient.getDebugMetadata(completion: completion) } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/IPC/DataBrokerProtectionIPCServer.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/IPC/DataBrokerProtectionIPCServer.swift index 7f4ae3e840..59998ea152 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/IPC/DataBrokerProtectionIPCServer.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/IPC/DataBrokerProtectionIPCServer.swift @@ -99,7 +99,6 @@ public protocol IPCServerInterface: AnyObject { completion: @escaping ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)) func runQueuedOperations(showWebView: Bool, completion: @escaping ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)) - func runAllOperations(showWebView: Bool) // MARK: - Debugging Features @@ -141,7 +140,6 @@ protocol XPCServerInterface { completion: @escaping ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)) func runQueuedOperations(showWebView: Bool, completion: @escaping ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)) - func runAllOperations(showWebView: Bool) // MARK: - Debugging Features @@ -226,10 +224,6 @@ extension DataBrokerProtectionIPCServer: XPCServerInterface { serverDelegate?.runQueuedOperations(showWebView: showWebView, completion: completion) } - func runAllOperations(showWebView: Bool) { - serverDelegate?.runAllOperations(showWebView: showWebView) - } - func openBrowser(domain: String) { serverDelegate?.openBrowser(domain: domain) } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperation.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperation.swift index 893ed9e350..9929c91b04 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperation.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperation.swift @@ -17,295 +17,205 @@ // import Foundation -import WebKit -import BrowserServicesKit -import UserScript import Common -protocol DataBrokerOperation: CCFCommunicationDelegate { - associatedtype ReturnValue - associatedtype InputValue - - var privacyConfig: PrivacyConfigurationManaging { get } - var prefs: ContentScopeProperties { get } - var query: BrokerProfileQueryData { get } - var emailService: EmailServiceProtocol { get } - var captchaService: CaptchaServiceProtocol { get } - var cookieHandler: CookieHandler { get } - var stageCalculator: StageDurationCalculator { get } - var pixelHandler: EventMapping { get } - - var webViewHandler: WebViewHandler? { get set } - var actionsHandler: ActionsHandler? { get } - var continuation: CheckedContinuation? { get set } - var extractedProfile: ExtractedProfile? { get set } - var shouldRunNextStep: () -> Bool { get } - var retriesCountOnError: Int { get set } - var clickAwaitTime: TimeInterval { get } - var postLoadingSiteStartTime: Date? { get set } - - func run(inputValue: InputValue, - webViewHandler: WebViewHandler?, - actionsHandler: ActionsHandler?, - showWebView: Bool) async throws -> ReturnValue - - func executeNextStep() async - func executeCurrentAction() async +protocol DataBrokerOperationErrorDelegate: AnyObject { + func dataBrokerOperation(_ dataBrokerOperation: DataBrokerOperation, + didError error: Error, + whileRunningBrokerOperationData: BrokerOperationData, + withDataBrokerName dataBrokerName: String?) + func dataBrokerOperation(_ dataBrokerOperation: DataBrokerOperation, + didErrorBeforeStartingBrokerOperations error: Error) } -extension DataBrokerOperation { - func run(inputValue: InputValue, - webViewHandler: WebViewHandler?, - actionsHandler: ActionsHandler?, - shouldRunNextStep: @escaping () -> Bool) async throws -> ReturnValue { - - try await run(inputValue: inputValue, - webViewHandler: webViewHandler, - actionsHandler: actionsHandler, - showWebView: false) - } -} - -extension DataBrokerOperation { - - // MARK: - Shared functions - - // swiftlint:disable:next cyclomatic_complexity - func runNextAction(_ action: Action) async { - switch action { - case is GetCaptchaInfoAction: - stageCalculator.setStage(.captchaParse) - case is ClickAction: - stageCalculator.setStage(.fillForm) - case is FillFormAction: - stageCalculator.setStage(.fillForm) - case is ExpectationAction: - stageCalculator.setStage(.submit) - default: () - } - - if let emailConfirmationAction = action as? EmailConfirmationAction { - do { - stageCalculator.fireOptOutSubmit() - try await runEmailConfirmationAction(action: emailConfirmationAction) - await executeNextStep() - } catch { - await onError(error: DataBrokerProtectionError.emailError(error as? EmailError)) - } - +internal class DataBrokerOperation: Operation { + + enum OperationType { + case scan + case optOut + case all + } + + public var error: Error? + public weak var errorDelegate: DataBrokerOperationErrorDelegate? + + private let dataBrokerID: Int64 + private let database: DataBrokerProtectionRepository + private let id = UUID() + private var _isExecuting = false + private var _isFinished = false + private let intervalBetweenOperations: TimeInterval? // The time in seconds to wait in-between operations + private let priorityDate: Date? // The date to filter and sort operations priorities + private let operationType: OperationType + private let notificationCenter: NotificationCenter + private let runner: WebOperationRunner + private let pixelHandler: EventMapping + private let showWebView: Bool + private let userNotificationService: DataBrokerProtectionUserNotificationService + + deinit { + os_log("Deinit operation: %{public}@", log: .dataBrokerProtection, String(describing: id.uuidString)) + } + + init(dataBrokerID: Int64, + database: DataBrokerProtectionRepository, + operationType: OperationType, + intervalBetweenOperations: TimeInterval? = nil, + priorityDate: Date? = nil, + notificationCenter: NotificationCenter = NotificationCenter.default, + runner: WebOperationRunner, + pixelHandler: EventMapping, + userNotificationService: DataBrokerProtectionUserNotificationService, + showWebView: Bool) { + + self.dataBrokerID = dataBrokerID + self.database = database + self.intervalBetweenOperations = intervalBetweenOperations + self.priorityDate = priorityDate + self.operationType = operationType + self.notificationCenter = notificationCenter + self.runner = runner + self.pixelHandler = pixelHandler + self.showWebView = showWebView + self.userNotificationService = userNotificationService + super.init() + } + + override func start() { + if isCancelled { + finish() return } - if action as? SolveCaptchaAction != nil, let captchaTransactionId = actionsHandler?.captchaTransactionId { - actionsHandler?.captchaTransactionId = nil - stageCalculator.setStage(.captchaSolve) - if let captchaData = try? await captchaService.submitCaptchaToBeResolved(for: captchaTransactionId, - attemptId: stageCalculator.attemptId, - shouldRunNextStep: shouldRunNextStep) { - stageCalculator.fireOptOutCaptchaSolve() - await webViewHandler?.execute(action: action, data: .solveCaptcha(CaptchaToken(token: captchaData))) - } else { - await onError(error: DataBrokerProtectionError.captchaServiceError(CaptchaServiceError.nilDataWhenFetchingCaptchaResult)) - } - - return - } + willChangeValue(forKey: #keyPath(isExecuting)) + _isExecuting = true + didChangeValue(forKey: #keyPath(isExecuting)) - if action.needsEmail { - do { - stageCalculator.setStage(.emailGenerate) - let emailData = try await emailService.getEmail(dataBrokerURL: query.dataBroker.url, attemptId: stageCalculator.attemptId) - extractedProfile?.email = emailData.emailAddress - stageCalculator.setEmailPattern(emailData.pattern) - stageCalculator.fireOptOutEmailGenerate() - } catch { - await onError(error: DataBrokerProtectionError.emailError(error as? EmailError)) - return - } - } - - await webViewHandler?.execute(action: action, data: .userData(query.profileQuery, self.extractedProfile)) + main() } - private func runEmailConfirmationAction(action: EmailConfirmationAction) async throws { - if let email = extractedProfile?.email { - stageCalculator.setStage(.emailReceive) - let url = try await emailService.getConfirmationLink( - from: email, - numberOfRetries: 100, // Move to constant - pollingInterval: action.pollingTime, - attemptId: stageCalculator.attemptId, - shouldRunNextStep: shouldRunNextStep - ) - stageCalculator.fireOptOutEmailReceive() - stageCalculator.setStage(.emailReceive) - do { - try await webViewHandler?.load(url: url) - } catch { - await onError(error: error) - return - } - - stageCalculator.fireOptOutEmailConfirm() - } else { - throw EmailError.cantFindEmail - } + override var isAsynchronous: Bool { + return true } - func complete(_ value: ReturnValue) { - self.firePostLoadingDurationPixel(hasError: false) - self.continuation?.resume(returning: value) - self.continuation = nil + override var isExecuting: Bool { + return _isExecuting } - func failed(with error: Error) { - self.firePostLoadingDurationPixel(hasError: true) - self.continuation?.resume(throwing: error) - self.continuation = nil + override var isFinished: Bool { + return _isFinished } - func initialize(handler: WebViewHandler?, - isFakeBroker: Bool = false, - showWebView: Bool) async { - if let handler = handler { // This help us swapping up the WebViewHandler on tests - self.webViewHandler = handler - } else { - self.webViewHandler = await DataBrokerProtectionWebViewHandler(privacyConfig: privacyConfig, prefs: prefs, delegate: self, isFakeBroker: isFakeBroker) + override func main() { + Task { + await runOperation() + finish() } - - await webViewHandler?.initializeWebView(showWebView: showWebView) } - // MARK: - CSSCommunicationDelegate + private func filterAndSortOperationsData(brokerProfileQueriesData: [BrokerProfileQueryData], operationType: OperationType, priorityDate: Date?) -> [BrokerOperationData] { + let operationsData: [BrokerOperationData] - func loadURL(url: URL) async { - let webSiteStartLoadingTime = Date() - - do { - // https://app.asana.com/0/1204167627774280/1206912494469284/f - if query.dataBroker.url == "spokeo.com" { - if let cookies = await cookieHandler.getAllCookiesFromDomain(url) { - await webViewHandler?.setCookies(cookies) - } - } - try await webViewHandler?.load(url: url) - fireSiteLoadingPixel(startTime: webSiteStartLoadingTime, hasError: false) - postLoadingSiteStartTime = Date() - await executeNextStep() - } catch { - fireSiteLoadingPixel(startTime: webSiteStartLoadingTime, hasError: true) - await onError(error: error) + switch operationType { + case .optOut: + operationsData = brokerProfileQueriesData.flatMap { $0.optOutOperationsData } + case .scan: + operationsData = brokerProfileQueriesData.compactMap { $0.scanOperationData } + case .all: + operationsData = brokerProfileQueriesData.flatMap { $0.operationsData } } - } - private func fireSiteLoadingPixel(startTime: Date, hasError: Bool) { - if stageCalculator.isManualScan { - let dataBrokerURL = self.query.dataBroker.url - let durationInMs = (Date().timeIntervalSince(startTime) * 1000).rounded(.towardZero) - pixelHandler.fire(.initialScanSiteLoadDuration(duration: durationInMs, hasError: hasError, brokerURL: dataBrokerURL)) - } - } + let filteredAndSortedOperationsData: [BrokerOperationData] - func firePostLoadingDurationPixel(hasError: Bool) { - if stageCalculator.isManualScan, let postLoadingSiteStartTime = self.postLoadingSiteStartTime { - let dataBrokerURL = self.query.dataBroker.url - let durationInMs = (Date().timeIntervalSince(postLoadingSiteStartTime) * 1000).rounded(.towardZero) - pixelHandler.fire(.initialScanPostLoadingDuration(duration: durationInMs, hasError: hasError, brokerURL: dataBrokerURL)) + if let priorityDate = priorityDate { + filteredAndSortedOperationsData = operationsData + .filter { $0.preferredRunDate != nil && $0.preferredRunDate! <= priorityDate } + .sorted { $0.preferredRunDate! < $1.preferredRunDate! } + } else { + filteredAndSortedOperationsData = operationsData } - } - func success(actionId: String, actionType: ActionType) async { - switch actionType { - case .click: - stageCalculator.fireOptOutFillForm() - // We wait 40 seconds before tapping - try? await Task.sleep(nanoseconds: UInt64(clickAwaitTime) * 1_000_000_000) - await executeNextStep() - case .fillForm: - stageCalculator.fireOptOutFillForm() - await executeNextStep() - default: await executeNextStep() - } + return filteredAndSortedOperationsData } - func captchaInformation(captchaInfo: GetCaptchaInfoResponse) async { - do { - stageCalculator.fireOptOutCaptchaParse() - stageCalculator.setStage(.captchaSend) - actionsHandler?.captchaTransactionId = try await captchaService.submitCaptchaInformation( - captchaInfo, - attemptId: stageCalculator.attemptId, - shouldRunNextStep: shouldRunNextStep) - stageCalculator.fireOptOutCaptchaSend() - await executeNextStep() - } catch { - if let captchaError = error as? CaptchaServiceError { - await onError(error: DataBrokerProtectionError.captchaServiceError(captchaError)) - } else { - await onError(error: DataBrokerProtectionError.captchaServiceError(.errorWhenSubmittingCaptcha)) - } - } - } + // swiftlint:disable:next function_body_length + private func runOperation() async { + let allBrokerProfileQueryData: [BrokerProfileQueryData] - func solveCaptcha(with response: SolveCaptchaResponse) async { do { - try await webViewHandler?.evaluateJavaScript(response.callback.eval) - - await executeNextStep() + allBrokerProfileQueryData = try database.fetchAllBrokerProfileQueryData() } catch { - await onError(error: DataBrokerProtectionError.solvingCaptchaWithCallbackError) + os_log("DataBrokerOperation error: runOperation, error: %{public}@", log: .error, error.localizedDescription) + errorDelegate?.dataBrokerOperation(self, didErrorBeforeStartingBrokerOperations: error) + return } - } - func onError(error: Error) async { - if retriesCountOnError > 0 { - await executeCurrentAction() - } else { - await webViewHandler?.finish() - failed(with: error) - } - } + let brokerProfileQueriesData = allBrokerProfileQueryData.filter { $0.dataBroker.id == dataBrokerID } - func executeCurrentAction() async { - let waitTimeUntilRunningTheActionAgain: TimeInterval = 3 - try? await Task.sleep(nanoseconds: UInt64(waitTimeUntilRunningTheActionAgain) * 1_000_000_000) + let filteredAndSortedOperationsData = filterAndSortOperationsData(brokerProfileQueriesData: brokerProfileQueriesData, + operationType: operationType, + priorityDate: priorityDate) - if let currentAction = self.actionsHandler?.currentAction() { - retriesCountOnError -= 1 - await runNextAction(currentAction) - } else { - retriesCountOnError = 0 - await onError(error: DataBrokerProtectionError.unknown("No current action to execute")) - } - } -} + os_log("filteredAndSortedOperationsData count: %{public}d for brokerID %{public}d", log: .dataBrokerProtection, filteredAndSortedOperationsData.count, dataBrokerID) -protocol CookieHandler { - func getAllCookiesFromDomain(_ url: URL) async -> [HTTPCookie]? -} + for operationData in filteredAndSortedOperationsData { + if isCancelled { + os_log("Cancelled operation, returning...", log: .dataBrokerProtection) + return + } -struct BrokerCookieHandler: CookieHandler { + let brokerProfileData = brokerProfileQueriesData.filter { + $0.dataBroker.id == operationData.brokerId && $0.profileQuery.id == operationData.profileQueryId + }.first - func getAllCookiesFromDomain(_ url: URL) async -> [HTTPCookie]? { - guard let domainURL = extractSchemeAndHostAsURL(from: url.absoluteString) else { return nil } - do { - let (_, response) = try await URLSession.shared.data(from: domainURL) - guard let httpResponse = response as? HTTPURLResponse, - let allHeaderFields = httpResponse.allHeaderFields as? [String: String] else { return nil } + guard let brokerProfileData = brokerProfileData else { + continue + } + do { + os_log("Running operation: %{public}@", log: .dataBrokerProtection, String(describing: operationData)) + + try await DataBrokerProfileQueryOperationManager().runOperation(operationData: operationData, + brokerProfileQueryData: brokerProfileData, + database: database, + notificationCenter: notificationCenter, + runner: runner, + pixelHandler: pixelHandler, + showWebView: showWebView, + isManualScan: operationType == .scan, + userNotificationService: userNotificationService, + shouldRunNextStep: { [weak self] in + guard let self = self else { return false } + return !self.isCancelled + }) + + if let sleepInterval = intervalBetweenOperations { + os_log("Waiting...: %{public}f", log: .dataBrokerProtection, sleepInterval) + try await Task.sleep(nanoseconds: UInt64(sleepInterval) * 1_000_000_000) + } - let cookies = HTTPCookie.cookies(withResponseHeaderFields: allHeaderFields, for: domainURL) - return cookies - } catch { - print("Error fetching data: \(error)") + } catch { + os_log("Error: %{public}@", log: .dataBrokerProtection, error.localizedDescription) + self.error = error + errorDelegate?.dataBrokerOperation(self, + didError: error, + whileRunningBrokerOperationData: operationData, + withDataBrokerName: brokerProfileQueriesData.first?.dataBroker.name) + } } - return nil + finish() } - private func extractSchemeAndHostAsURL(from url: String) -> URL? { - if let urlComponents = URLComponents(string: url), let scheme = urlComponents.scheme, let host = urlComponents.host { - return URL(string: "\(scheme)://\(host)") - } - return nil + private func finish() { + willChangeValue(forKey: #keyPath(isExecuting)) + willChangeValue(forKey: #keyPath(isFinished)) + + _isExecuting = false + _isFinished = true + + didChangeValue(forKey: #keyPath(isExecuting)) + didChangeValue(forKey: #keyPath(isFinished)) + + os_log("Finished operation: %{public}@", log: .dataBrokerProtection, String(describing: id.uuidString)) } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperationRunner.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperationRunner.swift index 52a64c3fac..23da5048f8 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperationRunner.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperationRunner.swift @@ -87,7 +87,7 @@ final class DataBrokerOperationRunner: WebOperationRunner { pixelHandler: EventMapping, showWebView: Bool, shouldRunNextStep: @escaping () -> Bool) async throws -> [ExtractedProfile] { - let scan = ScanOperation( + let scan = ScanTask( privacyConfig: privacyConfigManager, prefs: contentScopeProperties, query: profileQuery, @@ -106,7 +106,7 @@ final class DataBrokerOperationRunner: WebOperationRunner { pixelHandler: EventMapping, showWebView: Bool, shouldRunNextStep: @escaping () -> Bool) async throws { - let optOut = OptOutOperation( + let optOut = OptOutTask( privacyConfig: privacyConfigManager, prefs: contentScopeProperties, query: profileQuery, diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperationsBuilder.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperationsBuilder.swift new file mode 100644 index 0000000000..e6ade9b923 --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperationsBuilder.swift @@ -0,0 +1,81 @@ +// +// DataBrokerOperationsBuilder.swift +// +// 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 Common +import Foundation + +typealias OperationType = DataBrokerOperation.OperationType + +protocol OperationDependencies { + var database: DataBrokerProtectionRepository { get } + var config: SchedulerConfig { get } + var runnerProvider: OperationRunnerProvider { get } + var notificationCenter: NotificationCenter { get } + var pixelHandler: EventMapping { get } + var userNotificationService: DataBrokerProtectionUserNotificationService { get } +} + +struct DefaultOperationDependencies: OperationDependencies { + let database: DataBrokerProtectionRepository + let config: SchedulerConfig + let runnerProvider: OperationRunnerProvider + let notificationCenter: NotificationCenter + let pixelHandler: EventMapping + let userNotificationService: DataBrokerProtectionUserNotificationService +} + +protocol DataBrokerOperationsBuilder { + func operationCollections(operationType: OperationType, + priorityDate: Date?, + showWebView: Bool, + operationDependencies: OperationDependencies) throws -> [DataBrokerOperation] +} + +final class DefaultDataBrokerOperationsBuilder: DataBrokerOperationsBuilder { + + func operationCollections(operationType: OperationType, + priorityDate: Date?, + showWebView: Bool, + operationDependencies: OperationDependencies) throws -> [DataBrokerOperation] { + + let brokerProfileQueryData = try operationDependencies.database.fetchAllBrokerProfileQueryData() + var collections: [DataBrokerOperation] = [] + var visitedDataBrokerIDs: Set = [] + + for queryData in brokerProfileQueryData { + guard let dataBrokerID = queryData.dataBroker.id else { continue } + + if !visitedDataBrokerIDs.contains(dataBrokerID) { + let collection = DataBrokerOperation(dataBrokerID: dataBrokerID, + database: operationDependencies.database, + operationType: operationType, + intervalBetweenOperations: operationDependencies.config.intervalBetweenSameBrokerOperations, + priorityDate: priorityDate, + notificationCenter: operationDependencies.notificationCenter, + runner: operationDependencies.runnerProvider.getOperationRunner(), + pixelHandler: operationDependencies.pixelHandler, + userNotificationService: operationDependencies.userNotificationService, + showWebView: showWebView) + collections.append(collection) + visitedDataBrokerIDs.insert(dataBrokerID) + } + } + + return collections + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperationsCollection.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperationsCollection.swift deleted file mode 100644 index cbe22fe8fe..0000000000 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperationsCollection.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// DataBrokerOperationsCollection.swift -// -// Copyright © 2023 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 -import Common - -protocol DataBrokerOperationsCollectionErrorDelegate: AnyObject { - func dataBrokerOperationsCollection(_ dataBrokerOperationsCollection: DataBrokerOperationsCollection, - didError error: Error, - whileRunningBrokerOperationData: BrokerOperationData, - withDataBrokerName dataBrokerName: String?) - func dataBrokerOperationsCollection(_ dataBrokerOperationsCollection: DataBrokerOperationsCollection, - didErrorBeforeStartingBrokerOperations error: Error) -} - -final class DataBrokerOperationsCollection: Operation { - - enum OperationType { - case scan - case optOut - case all - } - - public var error: Error? - public weak var errorDelegate: DataBrokerOperationsCollectionErrorDelegate? - - private let dataBrokerID: Int64 - private let database: DataBrokerProtectionRepository - private let id = UUID() - private var _isExecuting = false - private var _isFinished = false - private let intervalBetweenOperations: TimeInterval? // The time in seconds to wait in-between operations - private let priorityDate: Date? // The date to filter and sort operations priorities - private let operationType: OperationType - private let notificationCenter: NotificationCenter - private let runner: WebOperationRunner - private let pixelHandler: EventMapping - private let showWebView: Bool - private let userNotificationService: DataBrokerProtectionUserNotificationService - - deinit { - os_log("Deinit operation: %{public}@", log: .dataBrokerProtection, String(describing: id.uuidString)) - } - - init(dataBrokerID: Int64, - database: DataBrokerProtectionRepository, - operationType: OperationType, - intervalBetweenOperations: TimeInterval? = nil, - priorityDate: Date? = nil, - notificationCenter: NotificationCenter = NotificationCenter.default, - runner: WebOperationRunner, - pixelHandler: EventMapping, - userNotificationService: DataBrokerProtectionUserNotificationService, - showWebView: Bool) { - - self.dataBrokerID = dataBrokerID - self.database = database - self.intervalBetweenOperations = intervalBetweenOperations - self.priorityDate = priorityDate - self.operationType = operationType - self.notificationCenter = notificationCenter - self.runner = runner - self.pixelHandler = pixelHandler - self.showWebView = showWebView - self.userNotificationService = userNotificationService - super.init() - } - - override func start() { - if isCancelled { - finish() - return - } - - willChangeValue(forKey: #keyPath(isExecuting)) - _isExecuting = true - didChangeValue(forKey: #keyPath(isExecuting)) - - main() - } - - override var isAsynchronous: Bool { - return true - } - - override var isExecuting: Bool { - return _isExecuting - } - - override var isFinished: Bool { - return _isFinished - } - - override func main() { - Task { - await runOperation() - finish() - } - } - - private func filterAndSortOperationsData(brokerProfileQueriesData: [BrokerProfileQueryData], operationType: OperationType, priorityDate: Date?) -> [BrokerOperationData] { - let operationsData: [BrokerOperationData] - - switch operationType { - case .optOut: - operationsData = brokerProfileQueriesData.flatMap { $0.optOutOperationsData } - case .scan: - operationsData = brokerProfileQueriesData.compactMap { $0.scanOperationData } - case .all: - operationsData = brokerProfileQueriesData.flatMap { $0.operationsData } - } - - let filteredAndSortedOperationsData: [BrokerOperationData] - - if let priorityDate = priorityDate { - filteredAndSortedOperationsData = operationsData - .filter { $0.preferredRunDate != nil && $0.preferredRunDate! <= priorityDate } - .sorted { $0.preferredRunDate! < $1.preferredRunDate! } - } else { - filteredAndSortedOperationsData = operationsData - } - - return filteredAndSortedOperationsData - } - - // swiftlint:disable:next function_body_length - private func runOperation() async { - let allBrokerProfileQueryData: [BrokerProfileQueryData] - - do { - allBrokerProfileQueryData = try database.fetchAllBrokerProfileQueryData() - } catch { - os_log("DataBrokerOperationsCollection error: runOperation, error: %{public}@", log: .error, error.localizedDescription) - errorDelegate?.dataBrokerOperationsCollection(self, didErrorBeforeStartingBrokerOperations: error) - return - } - - let brokerProfileQueriesData = allBrokerProfileQueryData.filter { $0.dataBroker.id == dataBrokerID } - - let filteredAndSortedOperationsData = filterAndSortOperationsData(brokerProfileQueriesData: brokerProfileQueriesData, - operationType: operationType, - priorityDate: priorityDate) - - os_log("filteredAndSortedOperationsData count: %{public}d for brokerID %{public}d", log: .dataBrokerProtection, filteredAndSortedOperationsData.count, dataBrokerID) - - for operationData in filteredAndSortedOperationsData { - if isCancelled { - os_log("Cancelled operation, returning...", log: .dataBrokerProtection) - return - } - - let brokerProfileData = brokerProfileQueriesData.filter { - $0.dataBroker.id == operationData.brokerId && $0.profileQuery.id == operationData.profileQueryId - }.first - - guard let brokerProfileData = brokerProfileData else { - continue - } - do { - os_log("Running operation: %{public}@", log: .dataBrokerProtection, String(describing: operationData)) - - try await DataBrokerProfileQueryOperationManager().runOperation(operationData: operationData, - brokerProfileQueryData: brokerProfileData, - database: database, - notificationCenter: notificationCenter, - runner: runner, - pixelHandler: pixelHandler, - showWebView: showWebView, - isManualScan: operationType == .scan, - userNotificationService: userNotificationService, - shouldRunNextStep: { [weak self] in - guard let self = self else { return false } - return !self.isCancelled - }) - - if let sleepInterval = intervalBetweenOperations { - os_log("Waiting...: %{public}f", log: .dataBrokerProtection, sleepInterval) - try await Task.sleep(nanoseconds: UInt64(sleepInterval) * 1_000_000_000) - } - - } catch { - os_log("Error: %{public}@", log: .dataBrokerProtection, error.localizedDescription) - self.error = error - errorDelegate?.dataBrokerOperationsCollection(self, - didError: error, - whileRunningBrokerOperationData: operationData, - withDataBrokerName: brokerProfileQueriesData.first?.dataBroker.name) - } - } - - finish() - } - - private func finish() { - willChangeValue(forKey: #keyPath(isExecuting)) - willChangeValue(forKey: #keyPath(isFinished)) - - _isExecuting = false - _isFinished = true - - didChangeValue(forKey: #keyPath(isExecuting)) - didChangeValue(forKey: #keyPath(isFinished)) - - os_log("Finished operation: %{public}@", log: .dataBrokerProtection, String(describing: id.uuidString)) - } -} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerTask.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerTask.swift new file mode 100644 index 0000000000..49523f074f --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerTask.swift @@ -0,0 +1,311 @@ +// +// DataBrokerTask.swift +// +// Copyright © 2023 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 +import WebKit +import BrowserServicesKit +import UserScript +import Common + +protocol DataBrokerTask: CCFCommunicationDelegate { + associatedtype ReturnValue + associatedtype InputValue + + var privacyConfig: PrivacyConfigurationManaging { get } + var prefs: ContentScopeProperties { get } + var query: BrokerProfileQueryData { get } + var emailService: EmailServiceProtocol { get } + var captchaService: CaptchaServiceProtocol { get } + var cookieHandler: CookieHandler { get } + var stageCalculator: StageDurationCalculator { get } + var pixelHandler: EventMapping { get } + + var webViewHandler: WebViewHandler? { get set } + var actionsHandler: ActionsHandler? { get } + var continuation: CheckedContinuation? { get set } + var extractedProfile: ExtractedProfile? { get set } + var shouldRunNextStep: () -> Bool { get } + var retriesCountOnError: Int { get set } + var clickAwaitTime: TimeInterval { get } + var postLoadingSiteStartTime: Date? { get set } + + func run(inputValue: InputValue, + webViewHandler: WebViewHandler?, + actionsHandler: ActionsHandler?, + showWebView: Bool) async throws -> ReturnValue + + func executeNextStep() async + func executeCurrentAction() async +} + +extension DataBrokerTask { + func run(inputValue: InputValue, + webViewHandler: WebViewHandler?, + actionsHandler: ActionsHandler?, + shouldRunNextStep: @escaping () -> Bool) async throws -> ReturnValue { + + try await run(inputValue: inputValue, + webViewHandler: webViewHandler, + actionsHandler: actionsHandler, + showWebView: false) + } +} + +extension DataBrokerTask { + + // MARK: - Shared functions + + // swiftlint:disable:next cyclomatic_complexity + func runNextAction(_ action: Action) async { + switch action { + case is GetCaptchaInfoAction: + stageCalculator.setStage(.captchaParse) + case is ClickAction: + stageCalculator.setStage(.fillForm) + case is FillFormAction: + stageCalculator.setStage(.fillForm) + case is ExpectationAction: + stageCalculator.setStage(.submit) + default: () + } + + if let emailConfirmationAction = action as? EmailConfirmationAction { + do { + stageCalculator.fireOptOutSubmit() + try await runEmailConfirmationAction(action: emailConfirmationAction) + await executeNextStep() + } catch { + await onError(error: DataBrokerProtectionError.emailError(error as? EmailError)) + } + + return + } + + if action as? SolveCaptchaAction != nil, let captchaTransactionId = actionsHandler?.captchaTransactionId { + actionsHandler?.captchaTransactionId = nil + stageCalculator.setStage(.captchaSolve) + if let captchaData = try? await captchaService.submitCaptchaToBeResolved(for: captchaTransactionId, + attemptId: stageCalculator.attemptId, + shouldRunNextStep: shouldRunNextStep) { + stageCalculator.fireOptOutCaptchaSolve() + await webViewHandler?.execute(action: action, data: .solveCaptcha(CaptchaToken(token: captchaData))) + } else { + await onError(error: DataBrokerProtectionError.captchaServiceError(CaptchaServiceError.nilDataWhenFetchingCaptchaResult)) + } + + return + } + + if action.needsEmail { + do { + stageCalculator.setStage(.emailGenerate) + let emailData = try await emailService.getEmail(dataBrokerURL: query.dataBroker.url, attemptId: stageCalculator.attemptId) + extractedProfile?.email = emailData.emailAddress + stageCalculator.setEmailPattern(emailData.pattern) + stageCalculator.fireOptOutEmailGenerate() + } catch { + await onError(error: DataBrokerProtectionError.emailError(error as? EmailError)) + return + } + } + + await webViewHandler?.execute(action: action, data: .userData(query.profileQuery, self.extractedProfile)) + } + + private func runEmailConfirmationAction(action: EmailConfirmationAction) async throws { + if let email = extractedProfile?.email { + stageCalculator.setStage(.emailReceive) + let url = try await emailService.getConfirmationLink( + from: email, + numberOfRetries: 100, // Move to constant + pollingInterval: action.pollingTime, + attemptId: stageCalculator.attemptId, + shouldRunNextStep: shouldRunNextStep + ) + stageCalculator.fireOptOutEmailReceive() + stageCalculator.setStage(.emailReceive) + do { + try await webViewHandler?.load(url: url) + } catch { + await onError(error: error) + return + } + + stageCalculator.fireOptOutEmailConfirm() + } else { + throw EmailError.cantFindEmail + } + } + + func complete(_ value: ReturnValue) { + self.firePostLoadingDurationPixel(hasError: false) + self.continuation?.resume(returning: value) + self.continuation = nil + } + + func failed(with error: Error) { + self.firePostLoadingDurationPixel(hasError: true) + self.continuation?.resume(throwing: error) + self.continuation = nil + } + + func initialize(handler: WebViewHandler?, + isFakeBroker: Bool = false, + showWebView: Bool) async { + if let handler = handler { // This help us swapping up the WebViewHandler on tests + self.webViewHandler = handler + } else { + self.webViewHandler = await DataBrokerProtectionWebViewHandler(privacyConfig: privacyConfig, prefs: prefs, delegate: self, isFakeBroker: isFakeBroker) + } + + await webViewHandler?.initializeWebView(showWebView: showWebView) + } + + // MARK: - CSSCommunicationDelegate + + func loadURL(url: URL) async { + let webSiteStartLoadingTime = Date() + + do { + // https://app.asana.com/0/1204167627774280/1206912494469284/f + if query.dataBroker.url == "spokeo.com" { + if let cookies = await cookieHandler.getAllCookiesFromDomain(url) { + await webViewHandler?.setCookies(cookies) + } + } + try await webViewHandler?.load(url: url) + fireSiteLoadingPixel(startTime: webSiteStartLoadingTime, hasError: false) + postLoadingSiteStartTime = Date() + await executeNextStep() + } catch { + fireSiteLoadingPixel(startTime: webSiteStartLoadingTime, hasError: true) + await onError(error: error) + } + } + + private func fireSiteLoadingPixel(startTime: Date, hasError: Bool) { + if stageCalculator.isManualScan { + let dataBrokerURL = self.query.dataBroker.url + let durationInMs = (Date().timeIntervalSince(startTime) * 1000).rounded(.towardZero) + pixelHandler.fire(.initialScanSiteLoadDuration(duration: durationInMs, hasError: hasError, brokerURL: dataBrokerURL)) + } + } + + func firePostLoadingDurationPixel(hasError: Bool) { + if stageCalculator.isManualScan, let postLoadingSiteStartTime = self.postLoadingSiteStartTime { + let dataBrokerURL = self.query.dataBroker.url + let durationInMs = (Date().timeIntervalSince(postLoadingSiteStartTime) * 1000).rounded(.towardZero) + pixelHandler.fire(.initialScanPostLoadingDuration(duration: durationInMs, hasError: hasError, brokerURL: dataBrokerURL)) + } + } + + func success(actionId: String, actionType: ActionType) async { + switch actionType { + case .click: + stageCalculator.fireOptOutFillForm() + // We wait 40 seconds before tapping + try? await Task.sleep(nanoseconds: UInt64(clickAwaitTime) * 1_000_000_000) + await executeNextStep() + case .fillForm: + stageCalculator.fireOptOutFillForm() + await executeNextStep() + default: await executeNextStep() + } + } + + func captchaInformation(captchaInfo: GetCaptchaInfoResponse) async { + do { + stageCalculator.fireOptOutCaptchaParse() + stageCalculator.setStage(.captchaSend) + actionsHandler?.captchaTransactionId = try await captchaService.submitCaptchaInformation( + captchaInfo, + attemptId: stageCalculator.attemptId, + shouldRunNextStep: shouldRunNextStep) + stageCalculator.fireOptOutCaptchaSend() + await executeNextStep() + } catch { + if let captchaError = error as? CaptchaServiceError { + await onError(error: DataBrokerProtectionError.captchaServiceError(captchaError)) + } else { + await onError(error: DataBrokerProtectionError.captchaServiceError(.errorWhenSubmittingCaptcha)) + } + } + } + + func solveCaptcha(with response: SolveCaptchaResponse) async { + do { + try await webViewHandler?.evaluateJavaScript(response.callback.eval) + + await executeNextStep() + } catch { + await onError(error: DataBrokerProtectionError.solvingCaptchaWithCallbackError) + } + } + + func onError(error: Error) async { + if retriesCountOnError > 0 { + await executeCurrentAction() + } else { + await webViewHandler?.finish() + failed(with: error) + } + } + + func executeCurrentAction() async { + let waitTimeUntilRunningTheActionAgain: TimeInterval = 3 + try? await Task.sleep(nanoseconds: UInt64(waitTimeUntilRunningTheActionAgain) * 1_000_000_000) + + if let currentAction = self.actionsHandler?.currentAction() { + retriesCountOnError -= 1 + await runNextAction(currentAction) + } else { + retriesCountOnError = 0 + await onError(error: DataBrokerProtectionError.unknown("No current action to execute")) + } + } +} + +protocol CookieHandler { + func getAllCookiesFromDomain(_ url: URL) async -> [HTTPCookie]? +} + +struct BrokerCookieHandler: CookieHandler { + + func getAllCookiesFromDomain(_ url: URL) async -> [HTTPCookie]? { + guard let domainURL = extractSchemeAndHostAsURL(from: url.absoluteString) else { return nil } + do { + let (_, response) = try await URLSession.shared.data(from: domainURL) + guard let httpResponse = response as? HTTPURLResponse, + let allHeaderFields = httpResponse.allHeaderFields as? [String: String] else { return nil } + + let cookies = HTTPCookie.cookies(withResponseHeaderFields: allHeaderFields, for: domainURL) + return cookies + } catch { + print("Error fetching data: \(error)") + } + + return nil + } + + private func extractSchemeAndHostAsURL(from url: String) -> URL? { + if let urlComponents = URLComponents(string: url), let scheme = urlComponents.scheme, let host = urlComponents.host { + return URL(string: "\(scheme)://\(host)") + } + return nil + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProtectionBrokerUpdater.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DefaultDataBrokerProtectionBrokerUpdater.swift similarity index 94% rename from LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProtectionBrokerUpdater.swift rename to LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DefaultDataBrokerProtectionBrokerUpdater.swift index cc0df841f6..990ffc513d 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProtectionBrokerUpdater.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DefaultDataBrokerProtectionBrokerUpdater.swift @@ -1,5 +1,5 @@ // -// DataBrokerProtectionBrokerUpdater.swift +// DefaultDataBrokerProtectionBrokerUpdater.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -98,7 +98,13 @@ final class AppVersionNumber: AppVersionNumberProvider { var versionNumber: String = AppVersion.shared.versionNumber } -public struct DataBrokerProtectionBrokerUpdater { +protocol DataBrokerProtectionBrokerUpdater { + static func provide() -> DefaultDataBrokerProtectionBrokerUpdater? + func updateBrokers() + func checkForUpdatesInBrokerJSONFiles() +} + +public struct DefaultDataBrokerProtectionBrokerUpdater: DataBrokerProtectionBrokerUpdater { private let repository: BrokerUpdaterRepository private let resources: ResourcesRepository @@ -118,9 +124,9 @@ public struct DataBrokerProtectionBrokerUpdater { self.pixelHandler = pixelHandler } - public static func provide() -> DataBrokerProtectionBrokerUpdater? { + public static func provide() -> DefaultDataBrokerProtectionBrokerUpdater? { if let vault = try? DataBrokerProtectionSecureVaultFactory.makeVault(reporter: DataBrokerProtectionSecureVaultErrorReporter.shared) { - return DataBrokerProtectionBrokerUpdater(vault: vault) + return DefaultDataBrokerProtectionBrokerUpdater(vault: vault) } os_log("Error when trying to create vault for data broker protection updater debug menu item", log: .dataBrokerProtection) diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OptOutOperation.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OptOutTask.swift similarity index 98% rename from LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OptOutOperation.swift rename to LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OptOutTask.swift index 0a957a62f8..c83e25c19b 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OptOutOperation.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OptOutTask.swift @@ -1,5 +1,5 @@ // -// OptOutOperation.swift +// OptOutTask.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -22,7 +22,7 @@ import BrowserServicesKit import UserScript import Common -final class OptOutOperation: DataBrokerOperation { +final class OptOutTask: DataBrokerTask { typealias ReturnValue = Void typealias InputValue = ExtractedProfile diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ParentChildRelationship/MismatchCalculatorUseCase.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ParentChildRelationship/MismatchCalculatorUseCase.swift index d4d83f7b2a..6eed251ad3 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ParentChildRelationship/MismatchCalculatorUseCase.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ParentChildRelationship/MismatchCalculatorUseCase.swift @@ -36,7 +36,12 @@ enum MismatchValues: Int { } } -struct MismatchCalculatorUseCase { +protocol MismatchCalculator { + init(database: DataBrokerProtectionRepository, pixelHandler: EventMapping) + func calculateMismatches() +} + +struct MismatchCalculatorUseCase: MismatchCalculator { let database: DataBrokerProtectionRepository let pixelHandler: EventMapping diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ScanOperation.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ScanTask.swift similarity index 98% rename from LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ScanOperation.swift rename to LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ScanTask.swift index f7bb9de995..eee84b54c2 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ScanOperation.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ScanTask.swift @@ -1,5 +1,5 @@ // -// ScanOperation.swift +// ScanTask.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -22,7 +22,7 @@ import BrowserServicesKit import UserScript import Common -final class ScanOperation: DataBrokerOperation { +final class ScanTask: DataBrokerTask { typealias ReturnValue = [ExtractedProfile] typealias InputValue = Void diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift index ad7f3cd61d..11c5fea673 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift @@ -130,7 +130,6 @@ public enum DataBrokerProtectionPixels { case ipcServerOptOutAllBrokersCompletion(error: Error?) case ipcServerRunQueuedOperations case ipcServerRunQueuedOperationsCompletion(error: Error?) - case ipcServerRunAllOperations // DataBrokerProtection User Notifications case dataBrokerProtectionNotificationSentFirstScanComplete @@ -247,7 +246,6 @@ extension DataBrokerProtectionPixels: PixelKitEvent { case .ipcServerOptOutAllBrokersCompletion: return "m_mac_dbp_ipc-server_opt-out-all-brokers_completion" case .ipcServerRunQueuedOperations: return "m_mac_dbp_ipc-server_run-queued-operations" case .ipcServerRunQueuedOperationsCompletion: return "m_mac_dbp_ipc-server_run-queued-operations_completion" - case .ipcServerRunAllOperations: return "m_mac_dbp_ipc-server_run-all-operations" // User Notifications case .dataBrokerProtectionNotificationSentFirstScanComplete: @@ -418,8 +416,7 @@ extension DataBrokerProtectionPixels: PixelKitEvent { .ipcServerOptOutAllBrokers, .ipcServerOptOutAllBrokersCompletion, .ipcServerRunQueuedOperations, - .ipcServerRunQueuedOperationsCompletion, - .ipcServerRunAllOperations: + .ipcServerRunQueuedOperationsCompletion: return [Consts.bundleIDParamKey: Bundle.main.bundleIdentifier ?? "nil"] case .scanSuccess(let dataBroker, let matchesFound, let duration, let tries, let isManualScan): return [Consts.dataBrokerParamKey: dataBroker, Consts.matchesFoundKey: String(matchesFound), Consts.durationParamKey: String(duration), Consts.triesKey: String(tries), Consts.isManualScan: isManualScan.description] @@ -507,7 +504,6 @@ public class DataBrokerProtectionPixelsHandler: EventMapping Void)?) { } func startManualScan(showWebView: Bool, startTime: Date, completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) { } - func runAllOperations(showWebView: Bool) { } func getDebugMetadata(completion: (DBPBackgroundAgentMetadata?) -> Void) { } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift index 74b381eb3e..624134d72f 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift @@ -94,29 +94,19 @@ final class DataBrokerProtectionProcessor { } } - func runAllOperations(showWebView: Bool = false, - completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)? = nil ) { - runOperations(operationType: .all, - priorityDate: nil, - showWebView: showWebView) { errors in - os_log("Queued operations done", log: .dataBrokerProtection) - completion?(errors) - } - } - func stopAllOperations() { operationQueue.cancelAllOperations() } // MARK: - Private functions - private func runOperations(operationType: DataBrokerOperationsCollection.OperationType, + private func runOperations(operationType: DataBrokerOperation.OperationType, priorityDate: Date?, showWebView: Bool, completion: @escaping ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)) { // Before running new operations we check if there is any updates to the broker files. if let vault = try? DataBrokerProtectionSecureVaultFactory.makeVault(reporter: DataBrokerProtectionSecureVaultErrorReporter.shared) { - let brokerUpdater = DataBrokerProtectionBrokerUpdater(vault: vault, pixelHandler: pixelHandler) + let brokerUpdater = DefaultDataBrokerProtectionBrokerUpdater(vault: vault, pixelHandler: pixelHandler) brokerUpdater.checkForUpdatesInBrokerJSONFiles() } @@ -125,7 +115,7 @@ final class DataBrokerProtectionProcessor { // This will try to fire the event weekly report pixels eventPixels.tryToFireWeeklyPixels() - let dataBrokerOperationCollections: [DataBrokerOperationsCollection] + let dataBrokerOperationCollections: [DataBrokerOperation] do { let brokersProfileData = try database.fetchAllBrokerProfileQueryData() @@ -153,18 +143,18 @@ final class DataBrokerProtectionProcessor { } private func createDataBrokerOperationCollections(from brokerProfileQueriesData: [BrokerProfileQueryData], - operationType: DataBrokerOperationsCollection.OperationType, + operationType: DataBrokerOperation.OperationType, priorityDate: Date?, - showWebView: Bool) -> [DataBrokerOperationsCollection] { + showWebView: Bool) -> [DataBrokerOperation] { - var collections: [DataBrokerOperationsCollection] = [] + var collections: [DataBrokerOperation] = [] var visitedDataBrokerIDs: Set = [] for queryData in brokerProfileQueriesData { guard let dataBrokerID = queryData.dataBroker.id else { continue } if !visitedDataBrokerIDs.contains(dataBrokerID) { - let collection = DataBrokerOperationsCollection(dataBrokerID: dataBrokerID, + let collection = DataBrokerOperation(dataBrokerID: dataBrokerID, database: database, operationType: operationType, intervalBetweenOperations: config.intervalBetweenSameBrokerOperations, @@ -189,13 +179,13 @@ final class DataBrokerProtectionProcessor { } } -extension DataBrokerProtectionProcessor: DataBrokerOperationsCollectionErrorDelegate { +extension DataBrokerProtectionProcessor: DataBrokerOperationErrorDelegate { - func dataBrokerOperationsCollection(_ dataBrokerOperationsCollection: DataBrokerOperationsCollection, didErrorBeforeStartingBrokerOperations error: Error) { + func dataBrokerOperation(_ dataBrokerOperation: DataBrokerOperation, didErrorBeforeStartingBrokerOperations error: Error) { } - func dataBrokerOperationsCollection(_ dataBrokerOperationsCollection: DataBrokerOperationsCollection, + func dataBrokerOperation(_ dataBrokerOperation: DataBrokerOperation, didError error: Error, whileRunningBrokerOperationData: BrokerOperationData, withDataBrokerName dataBrokerName: String?) { diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionQueueManager.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionQueueManager.swift new file mode 100644 index 0000000000..09d7e85eaf --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionQueueManager.swift @@ -0,0 +1,199 @@ +// +// DataBrokerProtectionQueueManager.swift +// +// 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 Common +import Foundation + +protocol DataBrokerProtectionOperationQueue { + func cancelAllOperations() + func addOperation(_ op: Operation) + func addBarrierBlock(_ barrier: @escaping @Sendable () -> Void) +} + +extension OperationQueue: DataBrokerProtectionOperationQueue {} + +protocol DataBrokerProtectionQueueManager { + var mode: QueueManagerMode { get } + + init(operationQueue: DataBrokerProtectionOperationQueue, + operationsBuilder: DataBrokerOperationsBuilder, + mismatchCalculator: MismatchCalculator, + brokerUpdater: DataBrokerProtectionBrokerUpdater?) + + func startImmediateOperationsIfPermitted(showWebView: Bool, + operationDependencies: OperationDependencies, + completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) + func startScheduledOperationsIfPermitted(showWebView: Bool, + operationDependencies: OperationDependencies, + completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) + + func startOptOutOperations(showWebView: Bool, + operationDependencies: OperationDependencies, + completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) + + func stopAllOperations() +} + +enum QueueManagerMode { + case idle + case immediate + case optOut + case scheduled + + func canInterrupt(forNewMode newMode: QueueManagerMode) -> Bool { + switch (self, newMode) { + case (_, .immediate): + return true + case (.idle, .scheduled): + return true + case (.immediate, .scheduled): + return false + default: + return false + } + } +} + +final class DefaultDataBrokerProtectionQueueManager: DataBrokerProtectionQueueManager { + + private(set) var mode: QueueManagerMode = .idle + + private let operationQueue: DataBrokerProtectionOperationQueue + private let operationsBuilder: DataBrokerOperationsBuilder + private let mismatchCalculator: MismatchCalculator + private let brokerUpdater: DataBrokerProtectionBrokerUpdater? + + init(operationQueue: DataBrokerProtectionOperationQueue, + operationsBuilder: DataBrokerOperationsBuilder, + mismatchCalculator: MismatchCalculator, + brokerUpdater: DataBrokerProtectionBrokerUpdater?) { + + self.operationQueue = operationQueue + self.operationsBuilder = operationsBuilder + self.mismatchCalculator = mismatchCalculator + self.brokerUpdater = brokerUpdater + } + + func startImmediateOperationsIfPermitted(showWebView: Bool, + operationDependencies: OperationDependencies, + completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) { + + guard mode.canInterrupt(forNewMode: .immediate) else { return } + mode = .immediate + + // New Manual scans ALWAYS interrupt (i.e cancel) ANY current Manual/Scheduled scans + operationQueue.cancelAllOperations() + + // Add manual operations to queue + addOperationCollections(withType: .scan, showWebView: showWebView, operationDependencies: operationDependencies) { [weak self] errors in + os_log("Manual scans completed", log: .dataBrokerProtection) + completion?(errors) + self?.mismatchCalculator.calculateMismatches() + } + } + + func startScheduledOperationsIfPermitted(showWebView: Bool, + operationDependencies: OperationDependencies, + completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) { + + guard mode.canInterrupt(forNewMode: .scheduled) else { return } + mode = .scheduled + + addOperationCollections(withType: .all, + priorityDate: Date(), + showWebView: showWebView, + operationDependencies: operationDependencies) { errors in + os_log("Queued operations completed", log: .dataBrokerProtection) + completion?(errors) + } + } + + func startOptOutOperations(showWebView: Bool, + operationDependencies: OperationDependencies, + completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) { + + // TODO: Correct interruption/cancellation behavior + // operationQueue.cancelAllOperations() + + addOperationCollections(withType: .optOut, showWebView: showWebView, operationDependencies: operationDependencies) { errors in + os_log("Opt-Outs completed", log: .dataBrokerProtection) + completion?(errors) + } + } + + func stopAllOperations() { + operationQueue.cancelAllOperations() + } +} + +private extension DefaultDataBrokerProtectionQueueManager { + + typealias OperationType = DataBrokerOperation.OperationType + + func addOperationCollections(withType type: OperationType, + priorityDate: Date? = nil, + showWebView: Bool, + operationDependencies: OperationDependencies, + completion: @escaping ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)) { + + // Update broker files if applicable + brokerUpdater?.checkForUpdatesInBrokerJSONFiles() + + // Fire Pixels + firePixels(operationDependencies: operationDependencies) + + // Use builder to build operations + let operations: [DataBrokerOperation] + do { + + operations = try operationsBuilder.operationCollections(operationType: type, + priorityDate: priorityDate, + showWebView: showWebView, + operationDependencies: operationDependencies) + + for collection in operations { + operationQueue.addOperation(collection) + } + } catch { + os_log("DataBrokerProtectionProcessor error: runOperations, error: %{public}@", log: .error, error.localizedDescription) + operationQueue.addBarrierBlock { + completion(DataBrokerProtectionSchedulerErrorCollection(oneTimeError: error)) + } + return + } + + operationQueue.addBarrierBlock { + let operationErrors = operations.compactMap { $0.error } + let errorCollection = operationErrors.count != 0 ? DataBrokerProtectionSchedulerErrorCollection(operationErrors: operationErrors) : nil + completion(errorCollection) + } + } + + private func firePixels(operationDependencies: OperationDependencies) { + let database = operationDependencies.database + let pixelHandler = operationDependencies.pixelHandler + + let engagementPixels = DataBrokerProtectionEngagementPixels(database: database, handler: pixelHandler) + let eventPixels = DataBrokerProtectionEventPixels(database: database, handler: pixelHandler) + + // This will fire the DAU/WAU/MAU pixels, + engagementPixels.fireEngagementPixel() + // This will try to fire the event weekly report pixels + eventPixels.tryToFireWeeklyPixels() + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionScheduler.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionScheduler.swift index 4e7d9a9846..6400a932c9 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionScheduler.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionScheduler.swift @@ -82,7 +82,6 @@ public protocol DataBrokerProtectionScheduler { func optOutAllBrokers(showWebView: Bool, completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) func startManualScan(showWebView: Bool, startTime: Date, completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) func runQueuedOperations(showWebView: Bool, completion: ((DataBrokerProtectionSchedulerErrorCollection?) -> Void)?) - func runAllOperations(showWebView: Bool) /// Debug operations @@ -94,10 +93,6 @@ extension DataBrokerProtectionScheduler { startScheduler(showWebView: false) } - public func runAllOperations() { - runAllOperations(showWebView: false) - } - public func startManualScan(startTime: Date) { startManualScan(showWebView: false, startTime: startTime, completion: nil) } @@ -142,19 +137,35 @@ public final class DefaultDataBrokerProtectionScheduler: DataBrokerProtectionSch private var lastSchedulerSessionStartTimestamp: Date? - private lazy var dataBrokerProcessor: DataBrokerProtectionProcessor = { + private lazy var queueManager: DataBrokerProtectionQueueManager = { + let operationQueue = OperationQueue() + operationQueue.maxConcurrentOperationCount = DataBrokerProtectionSchedulerConfig().concurrentOperationsDifferentBrokers + let operationsBuilder = DefaultDataBrokerOperationsBuilder() + let mismatchCalculator = MismatchCalculatorUseCase(database: dataManager.database, + pixelHandler: pixelHandler) + + var brokerUpdater: DataBrokerProtectionBrokerUpdater? + if let vault = try? DataBrokerProtectionSecureVaultFactory.makeVault(reporter: nil) { + brokerUpdater = DefaultDataBrokerProtectionBrokerUpdater(vault: vault, pixelHandler: pixelHandler) + } + return DefaultDataBrokerProtectionQueueManager(operationQueue: operationQueue, + operationsBuilder: operationsBuilder, + mismatchCalculator: mismatchCalculator, + brokerUpdater: brokerUpdater) + }() + + private lazy var operationDependencies: OperationDependencies = { let runnerProvider = DataBrokerOperationRunnerProvider(privacyConfigManager: privacyConfigManager, contentScopeProperties: contentScopeProperties, emailService: emailService, captchaService: captchaService) - return DataBrokerProtectionProcessor(database: dataManager.database, - config: DataBrokerProtectionSchedulerConfig(), - operationRunnerProvider: runnerProvider, - notificationCenter: notificationCenter, - pixelHandler: pixelHandler, - userNotificationService: userNotificationService) + return DefaultOperationDependencies(database: dataManager.database, + config: DataBrokerProtectionSchedulerConfig(), + runnerProvider: runnerProvider, + notificationCenter: notificationCenter, + pixelHandler: pixelHandler, userNotificationService: userNotificationService) }() public init(privacyConfigManager: PrivacyConfigurationManaging, @@ -205,7 +216,8 @@ public final class DefaultDataBrokerProtectionScheduler: DataBrokerProtectionSch self.status = .running os_log("Scheduler running...", log: .dataBrokerProtection) self.currentOperation = .queued - self.dataBrokerProcessor.runQueuedOperations(showWebView: showWebView) { [weak self] errors in + self.queueManager.startScheduledOperationsIfPermitted(showWebView: showWebView, + operationDependencies: self.operationDependencies) { [weak self] errors in if let errors = errors { if let oneTimeError = errors.oneTimeError { os_log("Error during startScheduler in dataBrokerProcessor.runQueuedOperations(), error: %{public}@", log: .dataBrokerProtection, oneTimeError.localizedDescription) @@ -227,30 +239,7 @@ public final class DefaultDataBrokerProtectionScheduler: DataBrokerProtectionSch os_log("Stopping scheduler...", log: .dataBrokerProtection) activity.invalidate() status = .stopped - dataBrokerProcessor.stopAllOperations() - } - - public func runAllOperations(showWebView: Bool = false) { - guard self.currentOperation != .manualScan else { - os_log("Manual scan in progress, returning...", log: .dataBrokerProtection) - return - } - - os_log("Running all operations...", log: .dataBrokerProtection) - self.currentOperation = .all - self.dataBrokerProcessor.runAllOperations(showWebView: showWebView) { [weak self] errors in - if let errors = errors { - if let oneTimeError = errors.oneTimeError { - os_log("Error during DefaultDataBrokerProtectionScheduler.runAllOperations in dataBrokerProcessor.runAllOperations(), error: %{public}@", log: .dataBrokerProtection, oneTimeError.localizedDescription) - self?.pixelHandler.fire(.generalError(error: oneTimeError, functionOccurredIn: "DefaultDataBrokerProtectionScheduler.runAllOperations")) - } - if let operationErrors = errors.operationErrors, - operationErrors.count != 0 { - os_log("Operation error(s) during DefaultDataBrokerProtectionScheduler.runAllOperations in dataBrokerProcessor.runAllOperations(), count: %{public}d", log: .dataBrokerProtection, operationErrors.count) - } - } - self?.currentOperation = .idle - } + self.queueManager.stopAllOperations() } public func runQueuedOperations(showWebView: Bool = false, @@ -262,8 +251,9 @@ public final class DefaultDataBrokerProtectionScheduler: DataBrokerProtectionSch os_log("Running queued operations...", log: .dataBrokerProtection) self.currentOperation = .queued - dataBrokerProcessor.runQueuedOperations(showWebView: showWebView, - completion: { [weak self] errors in + queueManager.startScheduledOperationsIfPermitted(showWebView: showWebView, + operationDependencies: operationDependencies) { [weak self] errors in + if let errors = errors { if let oneTimeError = errors.oneTimeError { os_log("Error during DefaultDataBrokerProtectionScheduler.runQueuedOperations in dataBrokerProcessor.runQueuedOperations(), error: %{public}@", log: .dataBrokerProtection, oneTimeError.localizedDescription) @@ -276,8 +266,8 @@ public final class DefaultDataBrokerProtectionScheduler: DataBrokerProtectionSch } completion?(errors) self?.currentOperation = .idle - }) + } } public func startManualScan(showWebView: Bool = false, @@ -290,7 +280,8 @@ public final class DefaultDataBrokerProtectionScheduler: DataBrokerProtectionSch userNotificationService.requestNotificationPermission() self.currentOperation = .manualScan os_log("Scanning all brokers...", log: .dataBrokerProtection) - dataBrokerProcessor.startManualScans(showWebView: showWebView) { [weak self] errors in + queueManager.startImmediateOperationsIfPermitted(showWebView: showWebView, + operationDependencies: operationDependencies) { [weak self ]errors in guard let self = self else { return } self.startScheduler(showWebView: showWebView) @@ -346,8 +337,9 @@ public final class DefaultDataBrokerProtectionScheduler: DataBrokerProtectionSch os_log("Opting out all brokers...", log: .dataBrokerProtection) self.currentOperation = .optOutAll - self.dataBrokerProcessor.runAllOptOutOperations(showWebView: showWebView, - completion: { [weak self] errors in + + queueManager.startOptOutOperations(showWebView: showWebView, + operationDependencies: operationDependencies) { [weak self] errors in if let errors = errors { if let oneTimeError = errors.oneTimeError { os_log("Error during DefaultDataBrokerProtectionScheduler.optOutAllBrokers in dataBrokerProcessor.runAllOptOutOperations(), error: %{public}@", log: .dataBrokerProtection, oneTimeError.localizedDescription) @@ -360,7 +352,7 @@ public final class DefaultDataBrokerProtectionScheduler: DataBrokerProtectionSch } self?.currentOperation = .idle completion?(errors) - }) + } } public func getDebugMetadata(completion: (DBPBackgroundAgentMetadata?) -> Void) { diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationActionTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationActionTests.swift index cdbb776170..896525a8c5 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationActionTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationActionTests.swift @@ -40,7 +40,7 @@ final class DataBrokerOperationActionTests: XCTestCase { let emailConfirmationAction = EmailConfirmationAction(id: "", actionType: .emailConfirmation, pollingTime: 1, dataSource: nil) let step = Step(type: .optOut, actions: [emailConfirmationAction]) let extractedProfile = ExtractedProfile(email: "test@duck.com") - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(with: [step]), @@ -65,7 +65,7 @@ final class DataBrokerOperationActionTests: XCTestCase { let emailConfirmationAction = EmailConfirmationAction(id: "", actionType: .emailConfirmation, pollingTime: 1, dataSource: nil) let step = Step(type: .optOut, actions: [emailConfirmationAction]) let noEmailExtractedProfile = ExtractedProfile() - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(with: [step]), @@ -97,7 +97,7 @@ final class DataBrokerOperationActionTests: XCTestCase { let step = Step(type: .optOut, actions: [emailConfirmationAction]) let extractedProfile = ExtractedProfile(email: "test@duck.com") emailService.shouldThrow = true - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(with: [step]), @@ -127,7 +127,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenActionNeedsEmail_thenExtractedProfileEmailIsSet() async { let fillFormAction = FillFormAction(id: "1", actionType: .fillForm, selector: "#test", elements: [.init(type: "email", selector: "#email", parent: nil)], dataSource: nil) let step = Step(type: .optOut, actions: [fillFormAction]) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(with: [step]), @@ -150,7 +150,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenGetEmailServiceFails_thenOperationThrows() async { let fillFormAction = FillFormAction(id: "1", actionType: .fillForm, selector: "#test", elements: [.init(type: "email", selector: "#email", parent: nil)], dataSource: nil) let step = Step(type: .optOut, actions: [fillFormAction]) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(with: [step]), @@ -178,7 +178,7 @@ final class DataBrokerOperationActionTests: XCTestCase { } func testWhenClickActionSucceeds_thenWeWaitForWebViewToLoad() async { - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -199,7 +199,7 @@ final class DataBrokerOperationActionTests: XCTestCase { } func testWhenAnActionThatIsNotClickSucceeds_thenWeDoNotWaitForWebViewToLoad() async { - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -221,7 +221,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenSolveCaptchaActionIsRun_thenCaptchaIsResolved() async { let solveCaptchaAction = SolveCaptchaAction(id: "1", actionType: .solveCaptcha, selector: "g-captcha", dataSource: nil) let step = Step(type: .optOut, actions: [solveCaptchaAction]) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -244,7 +244,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenSolveCapchaActionFailsToSubmitDataToTheBackend_thenOperationFails() async { let solveCaptchaAction = SolveCaptchaAction(id: "1", actionType: .solveCaptcha, selector: "g-captcha", dataSource: nil) let step = Step(type: .optOut, actions: [solveCaptchaAction]) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(with: [step]), @@ -274,7 +274,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenCaptchaInformationIsReturned_thenWeSubmitItTotTheBackend() async { let getCaptchaResponse = GetCaptchaInfoResponse(siteKey: "siteKey", url: "url", type: "recaptcha") let step = Step(type: .optOut, actions: [Action]()) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -297,7 +297,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenCaptchaInformationFailsToBeSubmitted_thenTheOperationFails() async { let getCaptchaResponse = GetCaptchaInfoResponse(siteKey: "siteKey", url: "url", type: "recaptcha") let step = Step(type: .optOut, actions: [Action]()) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -321,7 +321,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenRunningActionWithoutExtractedProfile_thenExecuteIsCalledWithProfileData() async { let expectationAction = ExpectationAction(id: "1", actionType: .expectation, expectations: [Item](), dataSource: nil) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -340,7 +340,7 @@ final class DataBrokerOperationActionTests: XCTestCase { } func testWhenLoadURLDelegateIsCalled_thenCorrectMethodIsExecutedOnWebViewHandler() async { - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -361,7 +361,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenGetCaptchaActionRuns_thenStageIsSetToCaptchaParse() async { let mockStageCalculator = MockStageDurationCalculator() let captchaAction = GetCaptchaInfoAction(id: "1", actionType: .getCaptchaInfo, selector: "captcha", dataSource: nil) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -381,7 +381,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenClickActionRuns_thenStageIsSetToSubmit() async { let mockStageCalculator = MockStageDurationCalculator() let clickAction = ClickAction(id: "1", actionType: .click, elements: [PageElement](), dataSource: nil) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -401,7 +401,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenExpectationActionRuns_thenStageIsSetToSubmit() async { let mockStageCalculator = MockStageDurationCalculator() let expectationAction = ExpectationAction(id: "1", actionType: .expectation, expectations: [Item](), dataSource: nil) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -421,7 +421,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenFillFormActionRuns_thenStageIsSetToFillForm() async { let mockStageCalculator = MockStageDurationCalculator() let fillFormAction = FillFormAction(id: "1", actionType: .fillForm, selector: "", elements: [PageElement](), dataSource: nil) - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(), @@ -440,7 +440,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenLoadUrlOnSpokeo_thenSetCookiesIsCalled() async { let mockCookieHandler = MockCookieHandler() - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(url: "spokeo.com"), @@ -462,7 +462,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenLoadUrlOnOtherBroker_thenSetCookiesIsNotCalled() async { let mockCookieHandler = MockCookieHandler() - let sut = OptOutOperation( + let sut = OptOutTask( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, query: BrokerProfileQueryData.mock(url: "verecor.com"), diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationsCollectionBuilderTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationsCollectionBuilderTests.swift new file mode 100644 index 0000000000..da4ce61a3e --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationsCollectionBuilderTests.swift @@ -0,0 +1,47 @@ +// +// DataBrokerOperationsCollectionBuilderTests.swift +// +// 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. +// + +@testable import DataBrokerProtection +import XCTest + +final class DataBrokerOperationsCollectionBuilderTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + 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 { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionQueueManagerTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionQueueManagerTests.swift new file mode 100644 index 0000000000..c67e82ccc1 --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionQueueManagerTests.swift @@ -0,0 +1,105 @@ +// +// DataBrokerProtectionQueueManagerTests.swift +// +// 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 +import Common +@testable import DataBrokerProtection + +final class DataBrokerProtectionQueueManagerTests: XCTestCase { + + var sut: DefaultDataBrokerProtectionQueueManager! + + var mockQueue: MockDataBrokerProtectionOperationQueue! + var mockOperations: [MockDataBrokerOperation]! + var mockOperationsBuilder: MockDataBrokerOperationsBuilder! + var mockDatabase: MockDatabase! + var mockPixelHandler: MockPixelHandler! + var mockMismatchCalculator: MockMismatchCalculator! + var mockUpdater: MockDataBrokerProtectionBrokerUpdater! + var mockSchedulerConfig: MockSchedulerConfig! + var mockRunnerProvider: MockRunnerProvider! + var mockUserNotification: MockUserNotification! + var mockDependencies: DefaultOperationDependencies! + + override func setUpWithError() throws { + mockQueue = MockDataBrokerProtectionOperationQueue() + mockOperations = (1...10).map { MockDataBrokerOperation(id: $0, operationType: .scan) } + mockOperationsBuilder = MockDataBrokerOperationsBuilder(operationCollections: mockOperations) + mockDatabase = MockDatabase() + mockPixelHandler = MockPixelHandler() + mockMismatchCalculator = MockMismatchCalculator(database: mockDatabase, pixelHandler: mockPixelHandler) + mockUpdater = MockDataBrokerProtectionBrokerUpdater() + mockSchedulerConfig = MockSchedulerConfig() + mockRunnerProvider = MockRunnerProvider() + mockUserNotification = MockUserNotification() + + mockDependencies = DefaultOperationDependencies(database: mockDatabase, + config: mockSchedulerConfig, + runnerProvider: mockRunnerProvider, + notificationCenter: .default, + pixelHandler: mockPixelHandler, + userNotificationService: mockUserNotification) + } + + func testWhenStartQueuedScan_andCurrentModeIsManual_thenCurrentOperationsAreNotInterrupted() throws { + // Given + sut = DefaultDataBrokerProtectionQueueManager(operationQueue: mockQueue, + operationsBuilder: mockOperationsBuilder, + mismatchCalculator: mockMismatchCalculator, + brokerUpdater: mockUpdater) + + // When + sut.startImmediateOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in } + + // Then + XCTAssert(mockQueue.didCallCancelCount == 1) + XCTAssert(mockQueue.operationCount == 10) + + mockOperations = (11...20).map { MockDataBrokerOperation(id: $0, operationType: .scan) } + mockOperationsBuilder.operationCollections = mockOperations + + sut.startScheduledOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in } + + XCTAssert(mockQueue.didCallCancelCount == 1) + XCTAssert(mockQueue.operationCount == 10) + } + + func testWhenStartSecondManualScan_andCurrentModeIsManual_thenCurrentOperationsAreInterrupted() throws { + // Given + sut = DefaultDataBrokerProtectionQueueManager(operationQueue: mockQueue, + operationsBuilder: mockOperationsBuilder, + mismatchCalculator: mockMismatchCalculator, + brokerUpdater: mockUpdater) + + // When + sut.startImmediateOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in } + + // Then + XCTAssert(mockQueue.didCallCancelCount == 1) + XCTAssert(mockQueue.operationCount == 10) + + mockOperations = (11...20).map { MockDataBrokerOperation(id: $0, operationType: .scan) } + mockOperationsBuilder.operationCollections = mockOperations + + sut.startImmediateOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in } + + XCTAssert(mockQueue.didCallCancelCount == 2) + XCTAssert(mockQueue.operations.filter { !$0.isCancelled }.count == 10) + XCTAssert(mockQueue.operations.filter { $0.isCancelled }.count >= 0) + } +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUpdaterTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUpdaterTests.swift index 2036ac9a1d..4f70f8934a 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUpdaterTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUpdaterTests.swift @@ -39,7 +39,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { func testWhenNoVersionIsStored_thenWeTryToUpdateBrokers() { if let vault = self.vault { - let sut = DataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) + let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) repository.lastCheckedVersion = nil sut.checkForUpdatesInBrokerJSONFiles() @@ -53,7 +53,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { func testWhenVersionIsStoredAndPatchIsLessThanCurrentOne_thenWeTryToUpdateBrokers() { if let vault = self.vault { - let sut = DataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault, appVersion: MockAppVersion(versionNumber: "1.74.1")) + let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault, appVersion: MockAppVersion(versionNumber: "1.74.1")) repository.lastCheckedVersion = "1.74.0" sut.checkForUpdatesInBrokerJSONFiles() @@ -67,7 +67,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { func testWhenVersionIsStoredAndMinorIsLessThanCurrentOne_thenWeTryToUpdateBrokers() { if let vault = self.vault { - let sut = DataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault, appVersion: MockAppVersion(versionNumber: "1.74.0")) + let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault, appVersion: MockAppVersion(versionNumber: "1.74.0")) repository.lastCheckedVersion = "1.73.0" sut.checkForUpdatesInBrokerJSONFiles() @@ -81,7 +81,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { func testWhenVersionIsStoredAndMajorIsLessThanCurrentOne_thenWeTryToUpdateBrokers() { if let vault = self.vault { - let sut = DataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault, appVersion: MockAppVersion(versionNumber: "1.74.0")) + let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault, appVersion: MockAppVersion(versionNumber: "1.74.0")) repository.lastCheckedVersion = "0.74.0" sut.checkForUpdatesInBrokerJSONFiles() @@ -95,7 +95,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { func testWhenVersionIsStoredAndIsEqualOrGreaterThanCurrentOne_thenCheckingUpdatesIsSkipped() { if let vault = self.vault { - let sut = DataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault, appVersion: MockAppVersion(versionNumber: "1.74.0")) + let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault, appVersion: MockAppVersion(versionNumber: "1.74.0")) repository.lastCheckedVersion = "1.74.0" sut.checkForUpdatesInBrokerJSONFiles() @@ -109,7 +109,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { func testWhenSavedBrokerIsOnAnOldVersion_thenWeUpdateIt() { if let vault = self.vault { - let sut = DataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) + let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) repository.lastCheckedVersion = nil resources.brokersList = [.init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.1", schedulingConfig: .mock)] vault.shouldReturnOldVersionBroker = true @@ -127,7 +127,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { func testWhenSavedBrokerIsOnTheCurrentVersion_thenWeDoNotUpdateIt() { if let vault = self.vault { - let sut = DataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) + let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) repository.lastCheckedVersion = nil resources.brokersList = [.init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.1", schedulingConfig: .mock)] vault.shouldReturnNewVersionBroker = true @@ -144,7 +144,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { func testWhenFileBrokerIsNotStored_thenWeAddTheBrokerAndScanOperations() { if let vault = self.vault { - let sut = DataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) + let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) repository.lastCheckedVersion = nil resources.brokersList = [.init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.0", schedulingConfig: .mock)] vault.profileQueries = [.mock] diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift index 9b60fe4812..44cb2413b8 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift @@ -960,3 +960,103 @@ final class MockDataBrokerProtectionBackendServicePixels: DataBrokerProtectionBa statusCode = nil } } + +final class MockDataBrokerProtectionOperationQueue: OperationQueue { + + private(set) var didCallCancelCount = 0 + private(set) var didCallAddCount = 0 + private(set) var didCallAddBarrierBlockCount = 0 + + override func cancelAllOperations() { + didCallCancelCount += 1 + super.cancelAllOperations() + } + + override func addOperation(_ op: Operation) { + didCallAddCount += 1 + super.addOperation(op) + } + + override func addBarrierBlock(_ barrier: @escaping @Sendable () -> Void) { + didCallAddBarrierBlockCount += 1 + super.addBarrierBlock(barrier) + } +} + +final class MockDataBrokerOperationsBuilder: DataBrokerOperationsBuilder { + + var operationCollections: [DataBrokerOperation] = [] + + init(operationCollections: [DataBrokerOperation]) { + self.operationCollections = operationCollections + } + + func operationCollections(operationType: OperationType, + priorityDate: Date?, + showWebView: Bool, + operationDependencies: any OperationDependencies) throws -> [DataBrokerOperation] { + operationCollections + } +} + +final class MockMismatchCalculator: MismatchCalculator { + + private(set) var didCallCalculateMismatches = false + + init(database: any DataBrokerProtectionRepository, pixelHandler: Common.EventMapping) { } + + func calculateMismatches() { + didCallCalculateMismatches = true + } +} + +final class MockDataBrokerProtectionBrokerUpdater: DataBrokerProtectionBrokerUpdater { + + private(set) var didCallUpdateBrokers = false + private(set) var didCallCheckForUpdates = false + + static func provide() -> DefaultDataBrokerProtectionBrokerUpdater? { + nil + } + + func updateBrokers() { + didCallUpdateBrokers = true + } + + func checkForUpdatesInBrokerJSONFiles() { + didCallCheckForUpdates = true + } +} + +final class MockPixelHandler: EventMapping { + init() { + super.init { event, _, _, _ in } + } +} + +final class MockSchedulerConfig: SchedulerConfig { + let concurrentOperationsDifferentBrokers = 1 + let intervalBetweenSameBrokerOperations: TimeInterval = 1 +} + +final class MockRunnerProvider: OperationRunnerProvider { + func getOperationRunner() -> any WebOperationRunner { + MockWebOperationRunner() + } +} + +final class MockDataBrokerOperation: DataBrokerOperation { + convenience init(id: Int64, operationType: OperationType) { + self.init(dataBrokerID: id, + database: MockDatabase(), + operationType: operationType, + runner: MockWebOperationRunner(), + pixelHandler: MockPixelHandler(), + userNotificationService: MockUserNotification(), + showWebView: false) + } + + override func main() { + Thread.sleep(forTimeInterval: 3) + } +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/QueueManagerModeTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/QueueManagerModeTests.swift new file mode 100644 index 0000000000..47141f3146 --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/QueueManagerModeTests.swift @@ -0,0 +1,81 @@ +// +// QueueManagerModeTests.swift +// +// 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. +// + +@testable import DataBrokerProtection +import XCTest + +final class QueueManagerModeTests: XCTestCase { + + func testCurrentModeIdle_andNewModeImmediate_thenInterruptionAllowed() throws { + // Given + let sut = QueueManagerMode.idle + + // When + let result = sut.canInterrupt(forNewMode: .immediate) + + // Then + XCTAssertTrue(result) + } + + func testCurrentModeIdle_andNewModeScheduled_thenInterruptionAllowed() throws { + // Given + let sut = QueueManagerMode.idle + + // When + let result = sut.canInterrupt(forNewMode: .scheduled) + + // Then + XCTAssertTrue(result) + } + + func testCurrentModeImmediate_andNewModeScheduled_thenInterruptionNotAllowed() throws { + // Given + let sut = QueueManagerMode.immediate + + // When + let result = sut.canInterrupt(forNewMode: .scheduled) + + // Then + XCTAssertFalse(result) + } + + func testCurrentModeScheduled_andNewModeImmediate_thenInterruptionAllowed() throws { + // Given + let sut = QueueManagerMode.scheduled + + // When + let result = sut.canInterrupt(forNewMode: .immediate) + + // Then + XCTAssertTrue(result) + } + + func testCurrentModeImmediate_andNewModeImmediate_thenInterruptionAllowed() throws { + // Given + let sut = QueueManagerMode.immediate + + // When + let result = sut.canInterrupt(forNewMode: .immediate) + + // Then + XCTAssertTrue(result) + } + + // TODO: Confirm all state change behavior + // TODO: Add Opt-out State Tests when behavior defined +}