From 40290b0aeebb10c3a216fe66edba3480a83b93ed Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 22 Feb 2024 19:28:44 +0000 Subject: [PATCH 1/2] Fix DBP decoding tests (#2244) Task/Issue URL:https://app.asana.com/0/1199230911884351/1206672196780651/f **Description**: Fix tests after the introduction of a new property --- .../Model/ExtractedProfile.swift | 2 +- ...DataBrokerProfileQueryOperationManagerTests.swift | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift index 1fd0366c8e..70a76305b3 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift @@ -134,7 +134,7 @@ struct ExtractedProfile: Codable, Sendable { addresses = try container.decodeIfPresent([AddressCityState].self, forKey: .addresses) phoneNumbers = try container.decodeIfPresent([String].self, forKey: .phoneNumbers) relatives = try container.decodeIfPresent([String].self, forKey: .relatives) - profileUrl = try container.decode(String.self, forKey: .profileUrl) + profileUrl = try container.decodeIfPresent(String.self, forKey: .profileUrl) reportId = try container.decodeIfPresent(String.self, forKey: .reportId) age = try container.decodeIfPresent(String.self, forKey: .age) email = try container.decodeIfPresent(String.self, forKey: .email) diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift index 5fa7f4b069..ca2ea7a3c0 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift @@ -98,8 +98,8 @@ final class DataBrokerProfileQueryOperationManagerTests: XCTestCase { let historyEvents = [HistoryEvent(extractedProfileId: extractedProfileId, brokerId: brokerId, profileQueryId: profileQueryId, type: .optOutRequested)] let mockScanOperation = ScanOperationData(brokerId: brokerId, profileQueryId: profileQueryId, preferredRunDate: currentPreferredRunDate, historyEvents: historyEvents) - let extractedProfileSaved1 = ExtractedProfile(id: 1, name: "Some name", profileUrl: "abc") - let extractedProfileSaved2 = ExtractedProfile(id: 1, name: "Some name", profileUrl: "zxz") + let extractedProfileSaved1 = ExtractedProfile(id: 1, name: "Some name", profileUrl: "abc", identifier: "abc") + let extractedProfileSaved2 = ExtractedProfile(id: 1, name: "Some name", profileUrl: "zxz", identifier: "zxz") let optOutData = [OptOutOperationData.mock(with: extractedProfileSaved1), OptOutOperationData.mock(with: extractedProfileSaved2)] @@ -907,19 +907,19 @@ extension ProfileQuery { extension ExtractedProfile { static var mockWithRemovedDate: ExtractedProfile { - ExtractedProfile(id: 1, name: "Some name", profileUrl: "someURL", removedDate: Date()) + ExtractedProfile(id: 1, name: "Some name", profileUrl: "someURL", removedDate: Date(), identifier: "someURL") } static var mockWithoutRemovedDate: ExtractedProfile { - ExtractedProfile(id: 1, name: "Some name", profileUrl: "someURL") + ExtractedProfile(id: 1, name: "Some name", profileUrl: "someURL", identifier: "someURL") } static var mockWithoutId: ExtractedProfile { - ExtractedProfile(name: "Some name", profileUrl: "someOtherURL") + ExtractedProfile(name: "Some name", profileUrl: "someOtherURL", identifier: "someOtherURL") } static func mockWithRemoveDate(_ date: Date) -> ExtractedProfile { - ExtractedProfile(id: 1, name: "Some name", profileUrl: "someURL", removedDate: date) + ExtractedProfile(id: 1, name: "Some name", profileUrl: "someURL", removedDate: date, identifier: "someURL") } } From c57419328e0b0678fa4bfc729b5cbd99a9c7abf1 Mon Sep 17 00:00:00 2001 From: Juan Manuel Pereira Date: Thu, 22 Feb 2024 16:57:17 -0300 Subject: [PATCH 2/2] DBP: Add engagement Pixels (#2238) --- DuckDuckGo/DBP/DBPHomeViewController.swift | 9 +- ...DataBrokerProtectionEngagementPixels.swift | 174 ++++++++++++++ .../Pixels/DataBrokerProtectionPixels.swift | 184 ++------------- ...kerProtectionStageDurationCalculator.swift | 184 +++++++++++++++ .../DataBrokerProtectionProcessor.swift | 5 + ...rokerProtectionEngagementPixelsTests.swift | 220 ++++++++++++++++++ .../MismatchCalculatorUseCaseTests.swift | 10 +- .../DataBrokerProtectionTests/Mocks.swift | 14 +- 8 files changed, 623 insertions(+), 177 deletions(-) create mode 100644 LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionEngagementPixels.swift create mode 100644 LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionStageDurationCalculator.swift create mode 100644 LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEngagementPixelsTests.swift diff --git a/DuckDuckGo/DBP/DBPHomeViewController.swift b/DuckDuckGo/DBP/DBPHomeViewController.swift index ab0e4f783b..4ffcdc68c8 100644 --- a/DuckDuckGo/DBP/DBPHomeViewController.swift +++ b/DuckDuckGo/DBP/DBPHomeViewController.swift @@ -49,8 +49,8 @@ final class DBPHomeViewController: NSViewController { let privacySettings = PrivacySecurityPreferences.shared let sessionKey = UUID().uuidString let prefs = ContentScopeProperties(gpcEnabled: privacySettings.gpcEnabled, - sessionKey: sessionKey, - featureToggles: features) + sessionKey: sessionKey, + featureToggles: features) return DataBrokerProtectionViewController( scheduler: dataBrokerProtectionManager.scheduler, @@ -191,7 +191,10 @@ public class DataBrokerProtectionPixelsHandler: EventMapping Date? + func getLatestWeeklyPixel() -> Date? + func getLatestMonthlyPixel() -> Date? +} + +final class DataBrokerProtectionEngagementPixelsUserDefaults: DataBrokerProtectionEngagementPixelsRepository { + + enum Consts { + static let dailyPixelKey = "macos.browser.data-broker-protection.dailyPixelKey" + static let weeklyPixelKey = "macos.browser.data-broker-protection.weeklyPixelKey" + static let monthlyPixelKey = "macos.browser.data-broker-protection.monthlyPixelKey" + } + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func markDailyPixelSent() { + userDefaults.set(Date(), forKey: Consts.dailyPixelKey) + } + + func markWeeklyPixelSent() { + userDefaults.set(Date(), forKey: Consts.weeklyPixelKey) + } + + func markMonthlyPixelSent() { + userDefaults.set(Date(), forKey: Consts.monthlyPixelKey) + } + + func getLatestDailyPixel() -> Date? { + userDefaults.object(forKey: Consts.dailyPixelKey) as? Date + } + + func getLatestWeeklyPixel() -> Date? { + userDefaults.object(forKey: Consts.weeklyPixelKey) as? Date + } + + func getLatestMonthlyPixel() -> Date? { + userDefaults.object(forKey: Consts.monthlyPixelKey) as? Date + } + +} + +/* + https://app.asana.com/0/1204586965688315/1206648312655381/f + + 1. When a user becomes an "Active User" of your feature, immediately fire individual pixels to register a DAU, a WAU and a MAU. Record (on-device) the date that the pixel was fired for each of the three events. e.g. + - DAU Pixel Last Sent 2024-02-20 + - WAU Pixel Last Sent 2024-02-20 + - MAU Pixel Last Sent 2024-02-20 + 2. If it is >= 1 date since the DAU pixel was last sent, send a new DAU pixel, and update the date with the current date + - DAU Pixel Last Sent 2024-02-21 + - WAU Pixel Last Sent 2024-02-20 + - MAU Pixel Last Sent 2024-02-20 + 3. If it is >= 7 dates since the WAU pixel was last sent, send a new WAU pixel and update the date with the current date + - DAU Pixel Last Sent 2024-02-27 + - WAU Pixel Last Sent 2024-02-27 + - MAU Pixel Last Sent 2024-02-20 + 4. If it is >= 28 dates since the MAU pixel was last sent, send a new MAU pixel and update the date with the current date: + - DAU Pixel Last Sent 2024-03-19 + - WAU Pixel Last Sent 2024-03-19 + - MAU Pixel Last Sent 2024-03-19 + */ +final class DataBrokerProtectionEngagementPixels { + + enum ActiveUserFrequency: Int { + case daily = 1 + case weekly = 7 + case monthly = 28 + } + + private let calendar = Calendar.current + private let database: DataBrokerProtectionRepository + private let repository: DataBrokerProtectionEngagementPixelsRepository + private let handler: EventMapping + + init(database: DataBrokerProtectionRepository, + handler: EventMapping, + repository: DataBrokerProtectionEngagementPixelsRepository = DataBrokerProtectionEngagementPixelsUserDefaults()) { + self.database = database + self.handler = handler + self.repository = repository + } + + func fireEngagementPixel(currentDate: Date = Date()) { + guard database.fetchProfile() != nil else { + os_log("No profile. We do not fire any pixel because we do not consider it an engaged user.", log: .dataBrokerProtection) + return + } + + if shouldWeFireDailyPixel(date: currentDate) { + handler.fire(.dailyActiveUser) + repository.markDailyPixelSent() + } + + if shouldWeFireWeeklyPixel(date: currentDate) { + handler.fire(.weeklyActiveUser) + repository.markWeeklyPixelSent() + } + + if shouldWeFireMonthlyPixel(date: currentDate) { + handler.fire(.monthlyActiveUser) + repository.markMonthlyPixelSent() + } + } + + private func shouldWeFireDailyPixel(date: Date) -> Bool { + guard let latestPixelFire = repository.getLatestDailyPixel() else { + return true + } + + return shouldWeFirePixel(startDate: latestPixelFire, endDate: date, daysDifference: .daily) + } + + private func shouldWeFireWeeklyPixel(date: Date) -> Bool { + guard let latestPixelFire = repository.getLatestWeeklyPixel() else { + return true + } + + return shouldWeFirePixel(startDate: latestPixelFire, endDate: date, daysDifference: .weekly) + } + + private func shouldWeFireMonthlyPixel(date: Date) -> Bool { + guard let latestPixelFire = repository.getLatestMonthlyPixel() else { + return true + } + + return shouldWeFirePixel(startDate: latestPixelFire, endDate: date, daysDifference: .monthly) + } + + private func shouldWeFirePixel(startDate: Date, endDate: Date, daysDifference: ActiveUserFrequency) -> Bool { + if let differenceBetweenDates = differenceBetweenDates(startDate: startDate, endDate: endDate) { + return differenceBetweenDates >= daysDifference.rawValue + } + + return false + } + + private func differenceBetweenDates(startDate: Date, endDate: Date) -> Int? { + let components = calendar.dateComponents([.day], from: startDate, to: endDate) + + return components.day + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift index 9f12bbab0c..a791dd55ee 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift @@ -39,168 +39,6 @@ enum ErrorCategory: Equatable { } } -enum Stage: String { - case start - case emailGenerate = "email-generate" - case captchaParse = "captcha-parse" - case captchaSend = "captcha-send" - case captchaSolve = "captcha-solve" - case submit - case emailReceive = "email-receive" - case emailConfirm = "email-confirm" - case validate - case other -} - -protocol StageDurationCalculator { - func durationSinceLastStage() -> Double - func durationSinceStartTime() -> Double - func fireOptOutStart() - func fireOptOutEmailGenerate() - func fireOptOutCaptchaParse() - func fireOptOutCaptchaSend() - func fireOptOutCaptchaSolve() - func fireOptOutSubmit() - func fireOptOutEmailReceive() - func fireOptOutEmailConfirm() - func fireOptOutValidate() - func fireOptOutSubmitSuccess() - func fireOptOutFailure() - func fireScanSuccess(matchesFound: Int) - func fireScanFailed() - func fireScanError(error: Error) - func setStage(_ stage: Stage) -} - -final class DataBrokerProtectionStageDurationCalculator: StageDurationCalculator { - let handler: EventMapping - let attemptId: UUID - let dataBroker: String - let startTime: Date - var lastStateTime: Date - var stage: Stage = .other - - init(attemptId: UUID = UUID(), - startTime: Date = Date(), - dataBroker: String, - handler: EventMapping) { - self.attemptId = attemptId - self.startTime = startTime - self.lastStateTime = startTime - self.dataBroker = dataBroker - self.handler = handler - } - - /// Returned in milliseconds - func durationSinceLastStage() -> Double { - let now = Date() - let durationSinceLastStage = now.timeIntervalSince(lastStateTime) * 1000 - self.lastStateTime = now - - return durationSinceLastStage.rounded(.towardZero) - } - - /// Returned in milliseconds - func durationSinceStartTime() -> Double { - let now = Date() - return (now.timeIntervalSince(startTime) * 1000).rounded(.towardZero) - } - - func fireOptOutStart() { - setStage(.start) - handler.fire(.optOutStart(dataBroker: dataBroker, attemptId: attemptId)) - } - - func fireOptOutEmailGenerate() { - handler.fire(.optOutEmailGenerate(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) - } - - func fireOptOutCaptchaParse() { - handler.fire(.optOutCaptchaParse(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) - } - - func fireOptOutCaptchaSend() { - handler.fire(.optOutCaptchaSend(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) - } - - func fireOptOutCaptchaSolve() { - handler.fire(.optOutCaptchaSolve(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) - } - - func fireOptOutSubmit() { - setStage(.submit) - handler.fire(.optOutSubmit(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) - } - - func fireOptOutEmailReceive() { - handler.fire(.optOutEmailReceive(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) - } - - func fireOptOutEmailConfirm() { - handler.fire(.optOutEmailConfirm(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) - } - - func fireOptOutValidate() { - setStage(.validate) - handler.fire(.optOutValidate(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) - } - - func fireOptOutSubmitSuccess() { - handler.fire(.optOutSubmitSuccess(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) - } - - func fireOptOutFailure() { - handler.fire(.optOutFailure(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceStartTime(), stage: stage.rawValue)) - } - - func fireScanSuccess(matchesFound: Int) { - handler.fire(.scanSuccess(dataBroker: dataBroker, matchesFound: matchesFound, duration: durationSinceStartTime(), tries: 1)) - } - - func fireScanFailed() { - handler.fire(.scanFailed(dataBroker: dataBroker, duration: durationSinceStartTime(), tries: 1)) - } - - func fireScanError(error: Error) { - var errorCategory: ErrorCategory = .unclassified - - if let dataBrokerProtectionError = error as? DataBrokerProtectionError { - switch dataBrokerProtectionError { - case .httpError(let httpCode): - if httpCode < 500 { - errorCategory = .clientError(httpCode: httpCode) - } else { - errorCategory = .serverError(httpCode: httpCode) - } - default: - errorCategory = .validationError - } - } else { - if let nsError = error as NSError? { - if nsError.domain == NSURLErrorDomain { - errorCategory = .networkError - } - } - } - - handler.fire( - .scanError( - dataBroker: dataBroker, - duration: durationSinceStartTime(), - category: errorCategory.toString, - details: error.localizedDescription - ) - ) - } - - // Helper methods to set the stage that is about to run. This help us - // identifying the stage so we can know which one was the one that failed. - - func setStage(_ stage: Stage) { - self.stage = stage - } -} - public enum DataBrokerProtectionPixels { struct Consts { static let dataBrokerParamKey = "data_broker" @@ -276,6 +114,11 @@ public enum DataBrokerProtectionPixels { case scanSuccess(dataBroker: String, matchesFound: Int, duration: Double, tries: Int) case scanFailed(dataBroker: String, duration: Double, tries: Int) case scanError(dataBroker: String, duration: Double, category: String, details: String) + + // KPIs - engagement + case dailyActiveUser + case weeklyActiveUser + case monthlyActiveUser } extension DataBrokerProtectionPixels: PixelKitEvent { @@ -331,7 +174,7 @@ extension DataBrokerProtectionPixels: PixelKitEvent { case .disableLoginItem: return "m_mac_dbp_login-item_disable" case .resetLoginItem: return "m_mac_dbp_login-item_reset" - // User Notifications + // User Notifications case .dataBrokerProtectionNotificationSentFirstScanComplete: return "m_mac_dbp_notification_sent_first_scan_complete" case .dataBrokerProtectionNotificationOpenedFirstScanComplete: @@ -348,6 +191,11 @@ extension DataBrokerProtectionPixels: PixelKitEvent { return "m_mac_dbp_notification_sent_all_records_removed" case .dataBrokerProtectionNotificationOpenedAllRecordsRemoved: return "m_mac_dbp_notification_opened_all_records_removed" + + // KPIs - engagement + case .dailyActiveUser: return "m_mac_dbp_engagement_dau" + case .weeklyActiveUser: return "m_mac_dbp_engagement_wau" + case .monthlyActiveUser: return "m_mac_dbp_engagement_mau" } } @@ -410,7 +258,10 @@ extension DataBrokerProtectionPixels: PixelKitEvent { .dataBrokerProtectionNotificationScheduled2WeeksCheckIn, .dataBrokerProtectionNotificationOpened2WeeksCheckIn, .dataBrokerProtectionNotificationSentAllRecordsRemoved, - .dataBrokerProtectionNotificationOpenedAllRecordsRemoved: + .dataBrokerProtectionNotificationOpenedAllRecordsRemoved, + .dailyActiveUser, + .weeklyActiveUser, + .monthlyActiveUser: return [:] case .ipcServerRegister, .ipcServerStartScheduler, @@ -485,7 +336,10 @@ public class DataBrokerProtectionPixelsHandler: EventMapping Double + func durationSinceStartTime() -> Double + func fireOptOutStart() + func fireOptOutEmailGenerate() + func fireOptOutCaptchaParse() + func fireOptOutCaptchaSend() + func fireOptOutCaptchaSolve() + func fireOptOutSubmit() + func fireOptOutEmailReceive() + func fireOptOutEmailConfirm() + func fireOptOutValidate() + func fireOptOutSubmitSuccess() + func fireOptOutFailure() + func fireScanSuccess(matchesFound: Int) + func fireScanFailed() + func fireScanError(error: Error) + func setStage(_ stage: Stage) +} + +final class DataBrokerProtectionStageDurationCalculator: StageDurationCalculator { + let handler: EventMapping + let attemptId: UUID + let dataBroker: String + let startTime: Date + var lastStateTime: Date + var stage: Stage = .other + + init(attemptId: UUID = UUID(), + startTime: Date = Date(), + dataBroker: String, + handler: EventMapping) { + self.attemptId = attemptId + self.startTime = startTime + self.lastStateTime = startTime + self.dataBroker = dataBroker + self.handler = handler + } + + /// Returned in milliseconds + func durationSinceLastStage() -> Double { + let now = Date() + let durationSinceLastStage = now.timeIntervalSince(lastStateTime) * 1000 + self.lastStateTime = now + + return durationSinceLastStage.rounded(.towardZero) + } + + /// Returned in milliseconds + func durationSinceStartTime() -> Double { + let now = Date() + return (now.timeIntervalSince(startTime) * 1000).rounded(.towardZero) + } + + func fireOptOutStart() { + setStage(.start) + handler.fire(.optOutStart(dataBroker: dataBroker, attemptId: attemptId)) + } + + func fireOptOutEmailGenerate() { + handler.fire(.optOutEmailGenerate(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) + } + + func fireOptOutCaptchaParse() { + handler.fire(.optOutCaptchaParse(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) + } + + func fireOptOutCaptchaSend() { + handler.fire(.optOutCaptchaSend(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) + } + + func fireOptOutCaptchaSolve() { + handler.fire(.optOutCaptchaSolve(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) + } + + func fireOptOutSubmit() { + setStage(.submit) + handler.fire(.optOutSubmit(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) + } + + func fireOptOutEmailReceive() { + handler.fire(.optOutEmailReceive(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) + } + + func fireOptOutEmailConfirm() { + handler.fire(.optOutEmailConfirm(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) + } + + func fireOptOutValidate() { + setStage(.validate) + handler.fire(.optOutValidate(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) + } + + func fireOptOutSubmitSuccess() { + handler.fire(.optOutSubmitSuccess(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceLastStage())) + } + + func fireOptOutFailure() { + handler.fire(.optOutFailure(dataBroker: dataBroker, attemptId: attemptId, duration: durationSinceStartTime(), stage: stage.rawValue)) + } + + func fireScanSuccess(matchesFound: Int) { + handler.fire(.scanSuccess(dataBroker: dataBroker, matchesFound: matchesFound, duration: durationSinceStartTime(), tries: 1)) + } + + func fireScanFailed() { + handler.fire(.scanFailed(dataBroker: dataBroker, duration: durationSinceStartTime(), tries: 1)) + } + + func fireScanError(error: Error) { + var errorCategory: ErrorCategory = .unclassified + + if let dataBrokerProtectionError = error as? DataBrokerProtectionError { + switch dataBrokerProtectionError { + case .httpError(let httpCode): + if httpCode < 500 { + errorCategory = .clientError(httpCode: httpCode) + } else { + errorCategory = .serverError(httpCode: httpCode) + } + default: + errorCategory = .validationError + } + } else { + if let nsError = error as NSError? { + if nsError.domain == NSURLErrorDomain { + errorCategory = .networkError + } + } + } + + handler.fire( + .scanError( + dataBroker: dataBroker, + duration: durationSinceStartTime(), + category: errorCategory.toString, + details: error.localizedDescription + ) + ) + } + + // Helper methods to set the stage that is about to run. This help us + // identifying the stage so we can know which one was the one that failed. + + func setStage(_ stage: Stage) { + self.stage = stage + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift index c61178c0f9..0eddaad17e 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift @@ -32,6 +32,7 @@ final class DataBrokerProtectionProcessor { private let operationQueue: OperationQueue private var pixelHandler: EventMapping private let userNotificationService: DataBrokerProtectionUserNotificationService + private let engagementPixels: DataBrokerProtectionEngagementPixels init(database: DataBrokerProtectionRepository, config: SchedulerConfig, @@ -48,6 +49,7 @@ final class DataBrokerProtectionProcessor { self.pixelHandler = pixelHandler self.operationQueue.maxConcurrentOperationCount = config.concurrentOperationsDifferentBrokers self.userNotificationService = userNotificationService + self.engagementPixels = DataBrokerProtectionEngagementPixels(database: database, handler: pixelHandler) } // MARK: - Public functions @@ -111,6 +113,9 @@ final class DataBrokerProtectionProcessor { brokerUpdater.checkForUpdatesInBrokerJSONFiles() } + // This will fire the DAU/WAU/MAU pixels, + engagementPixels.fireEngagementPixel() + let brokersProfileData = database.fetchAllBrokerProfileQueryData() let dataBrokerOperationCollections = createDataBrokerOperationCollections(from: brokersProfileData, operationType: operationType, diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEngagementPixelsTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEngagementPixelsTests.swift new file mode 100644 index 0000000000..7779cc0d00 --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEngagementPixelsTests.swift @@ -0,0 +1,220 @@ +// +// DataBrokerProtectionEngagementPixelsTests.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 XCTest +import Foundation +@testable import DataBrokerProtection + +final class DataBrokerProtectionEngagementPixelsTests: XCTestCase { + + private let database = MockDatabase() + private let repository = MockDataBrokerProtectionEngagementPixelsRepository() + private let handler = MockDataBrokerProtectionPixelsHandler() + + private var fakeProfile: DataBrokerProtectionProfile { + let name = DataBrokerProtectionProfile.Name(firstName: "John", lastName: "Doe") + let address = DataBrokerProtectionProfile.Address(city: "City", state: "State") + + return DataBrokerProtectionProfile(names: [name], addresses: [address], phones: [String](), birthYear: 1900) + } + + override func tearDown() { + database.clear() + repository.clear() + handler.clear() + } + + func testWhenThereIsNoProfile_thenNoEngagementPixelIsFired() { + database.setFetchedProfile(nil) + let sut = DataBrokerProtectionEngagementPixels(database: database, handler: handler, repository: repository) + + sut.fireEngagementPixel(currentDate: Date()) + + // We test we have no interactions with the repository + XCTAssertFalse(repository.wasDailyPixelSent) + XCTAssertFalse(repository.wasWeeklyPixelSent) + XCTAssertFalse(repository.wasMonthlyPixelSent) + XCTAssertFalse(repository.wasGetLatestDailyPixelCalled) + XCTAssertFalse(repository.wasWeeklyPixelSent) + XCTAssertFalse(repository.wasMonthlyPixelSent) + + // The pixel should not be fired + XCTAssertTrue(MockDataBrokerProtectionPixelsHandler.lastPixelsFired.isEmpty) + } + + func testWhenLatestDailyPixelIsNil_thenWeFireDailyPixel() { + database.setFetchedProfile(fakeProfile) + repository.setLatestDailyPixel = nil + let sut = DataBrokerProtectionEngagementPixels(database: database, handler: handler, repository: repository) + + sut.fireEngagementPixel(currentDate: Date()) + + XCTAssertTrue(wasPixelFired(.dailyActiveUser)) + XCTAssertTrue(repository.wasDailyPixelSent) + } + + func testWhenCurrentDayIsDifferentToLatestDailyPixel_thenWeFireDailyPixel() { + database.setFetchedProfile(fakeProfile) + repository.setLatestWeeklyPixel = dateFromString("2024-02-20") + let sut = DataBrokerProtectionEngagementPixels(database: database, handler: handler, repository: repository) + + sut.fireEngagementPixel(currentDate: dateFromString("2024-02-21")) + + XCTAssertTrue(wasPixelFired(.dailyActiveUser)) + XCTAssertTrue(repository.wasDailyPixelSent) + } + + func testWhenCurrentDayIsEqualToLatestDailyPixel_thenWeDoNotFireDailyPixel() { + database.setFetchedProfile(fakeProfile) + repository.setLatestDailyPixel = Date() + let sut = DataBrokerProtectionEngagementPixels(database: database, handler: handler, repository: repository) + + sut.fireEngagementPixel(currentDate: Date()) + + XCTAssertFalse(wasPixelFired(.dailyActiveUser)) + XCTAssertFalse(repository.wasDailyPixelSent) + } + + func testWhenLatestWeeklyPixelIsNil_thenWeFireWeeklyPixel() { + database.setFetchedProfile(fakeProfile) + repository.setLatestWeeklyPixel = nil + let sut = DataBrokerProtectionEngagementPixels(database: database, handler: handler, repository: repository) + + sut.fireEngagementPixel(currentDate: Date()) + + XCTAssertTrue(wasPixelFired(.weeklyActiveUser)) + XCTAssertTrue(repository.wasWeeklyPixelSent) + } + + func testWhenCurrentDayIsSevenDatesEqualOrGreaterThanLatestWeekly_thenWeFireWeeklyPixel() { + database.setFetchedProfile(fakeProfile) + repository.setLatestWeeklyPixel = dateFromString("2024-02-20") + let sut = DataBrokerProtectionEngagementPixels(database: database, handler: handler, repository: repository) + + sut.fireEngagementPixel(currentDate: dateFromString("2024-02-27")) + + XCTAssertTrue(wasPixelFired(.weeklyActiveUser)) + XCTAssertTrue(repository.wasWeeklyPixelSent) + } + + func testWhenCurrentDayIsSevenDatesLessThanLatestWeekly_thenWeDoNotFireWeeklyPixel() { + database.setFetchedProfile(fakeProfile) + repository.setLatestWeeklyPixel = dateFromString("2024-02-20") + let sut = DataBrokerProtectionEngagementPixels(database: database, handler: handler, repository: repository) + + sut.fireEngagementPixel(currentDate: dateFromString("2024-02-26")) + + XCTAssertFalse(wasPixelFired(.weeklyActiveUser)) + XCTAssertFalse(repository.wasWeeklyPixelSent) + } + + func testWhenLatestMonthlyPixelIsNil_thenWeFireMonthlyPixel() { + database.setFetchedProfile(fakeProfile) + repository.setLatestMonthlyPixel = nil + let sut = DataBrokerProtectionEngagementPixels(database: database, handler: handler, repository: repository) + + sut.fireEngagementPixel(currentDate: Date()) + + XCTAssertTrue(wasPixelFired(.monthlyActiveUser)) + XCTAssertTrue(repository.wasMonthlyPixelSent) + } + + func testWhenCurrentMonthIs28DatesGreaterOrEqualThanLatestMonthlyPixel_thenWeFireMonthlyPixel() { + database.setFetchedProfile(fakeProfile) + repository.setLatestMonthlyPixel = dateFromString("2024-02-20") + let sut = DataBrokerProtectionEngagementPixels(database: database, handler: handler, repository: repository) + + sut.fireEngagementPixel(currentDate: dateFromString("2024-03-19")) + + XCTAssertTrue(wasPixelFired(.monthlyActiveUser)) + XCTAssertTrue(repository.wasMonthlyPixelSent) + } + + func testWhenCurrentIsNot28DatesGreaterOrEqualToLatestMonthlyPixel_thenWeDoNotFireMonthlyPixel() { + database.setFetchedProfile(fakeProfile) + repository.setLatestMonthlyPixel = dateFromString("2024-02-20") + let sut = DataBrokerProtectionEngagementPixels(database: database, handler: handler, repository: repository) + + sut.fireEngagementPixel(currentDate: dateFromString("2024-03-18")) + + XCTAssertFalse(wasPixelFired(.monthlyActiveUser)) + XCTAssertFalse(repository.wasMonthlyPixelSent) + } + + private func wasPixelFired(_ pixel: DataBrokerProtectionPixels) -> Bool { + MockDataBrokerProtectionPixelsHandler.lastPixelsFired.contains(where: { $0.name == pixel.name }) + } + + private func dateFromString(_ string: String) -> Date { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + return dateFormatter.date(from: string)! + } +} + +final class MockDataBrokerProtectionEngagementPixelsRepository: DataBrokerProtectionEngagementPixelsRepository { + var wasDailyPixelSent = false + var wasWeeklyPixelSent = false + var wasMonthlyPixelSent = false + var wasGetLatestDailyPixelCalled = false + var wasGetLatestWeeklyPixelCalled = false + var wasGetLatestMonthlyPixelCalled = false + var setLatestDailyPixel: Date? + var setLatestWeeklyPixel: Date? + var setLatestMonthlyPixel: Date? + + func markDailyPixelSent() { + wasDailyPixelSent = true + } + + func markWeeklyPixelSent() { + wasWeeklyPixelSent = true + } + + func markMonthlyPixelSent() { + wasMonthlyPixelSent = true + } + + func getLatestDailyPixel() -> Date? { + wasGetLatestDailyPixelCalled = true + return setLatestDailyPixel + } + + func getLatestWeeklyPixel() -> Date? { + wasGetLatestWeeklyPixelCalled = true + return setLatestWeeklyPixel + } + + func getLatestMonthlyPixel() -> Date? { + wasGetLatestMonthlyPixelCalled = true + return setLatestMonthlyPixel + } + + func clear() { + wasDailyPixelSent = false + wasWeeklyPixelSent = false + wasMonthlyPixelSent = false + wasGetLatestDailyPixelCalled = false + wasGetLatestWeeklyPixelCalled = false + wasGetLatestMonthlyPixelCalled = false + setLatestDailyPixel = nil + setLatestWeeklyPixel = nil + setLatestMonthlyPixel = nil + } +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MismatchCalculatorUseCaseTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MismatchCalculatorUseCaseTests.swift index 12b7120077..db0607d891 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MismatchCalculatorUseCaseTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MismatchCalculatorUseCaseTests.swift @@ -47,7 +47,7 @@ final class MismatchCalculatorUseCaseTests: XCTestCase { sut.calculateMismatches() - let lastPixel = MockDataBrokerProtectionPixelsHandler.lastPixelFired! + let lastPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.first! let pixelName = DataBrokerProtectionPixels.parentChildMatches(parent: "", child: "", value: 0).name XCTAssertEqual(lastPixel.name, pixelName) XCTAssertEqual(Int((lastPixel.params?["value"])!), @@ -72,7 +72,7 @@ final class MismatchCalculatorUseCaseTests: XCTestCase { sut.calculateMismatches() - let lastPixel = MockDataBrokerProtectionPixelsHandler.lastPixelFired! + let lastPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.first! let pixelName = DataBrokerProtectionPixels.parentChildMatches(parent: "", child: "", value: 0).name XCTAssertEqual(lastPixel.name, pixelName) XCTAssertEqual(Int((lastPixel.params?["value"])!), @@ -97,7 +97,7 @@ final class MismatchCalculatorUseCaseTests: XCTestCase { sut.calculateMismatches() - let lastPixel = MockDataBrokerProtectionPixelsHandler.lastPixelFired! + let lastPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.first! let pixelName = DataBrokerProtectionPixels.parentChildMatches(parent: "", child: "", value: 0).name XCTAssertEqual(lastPixel.name, pixelName) XCTAssertEqual(Int((lastPixel.params?["value"])!), @@ -122,7 +122,7 @@ final class MismatchCalculatorUseCaseTests: XCTestCase { sut.calculateMismatches() - let lastPixel = MockDataBrokerProtectionPixelsHandler.lastPixelFired! + let lastPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.first! let pixelName = DataBrokerProtectionPixels.parentChildMatches(parent: "", child: "", value: 0).name XCTAssertEqual(lastPixel.name, pixelName) XCTAssertEqual(Int((lastPixel.params?["value"])!), @@ -143,7 +143,7 @@ final class MismatchCalculatorUseCaseTests: XCTestCase { sut.calculateMismatches() - XCTAssertNil(MockDataBrokerProtectionPixelsHandler.lastPixelFired) + XCTAssertTrue(MockDataBrokerProtectionPixelsHandler.lastPixelsFired.isEmpty) } } diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift index 95ea0d1493..575c0cdd17 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift @@ -620,11 +620,11 @@ final class DataBrokerProtectionSecureVaultMock: DataBrokerProtectionSecureVault public class MockDataBrokerProtectionPixelsHandler: EventMapping { - static var lastPixelFired: DataBrokerProtectionPixels? + static var lastPixelsFired = [DataBrokerProtectionPixels]() public init() { super.init { event, _, _, _ in - MockDataBrokerProtectionPixelsHandler.lastPixelFired = event + MockDataBrokerProtectionPixelsHandler.lastPixelsFired.append(event) } } @@ -633,7 +633,7 @@ public class MockDataBrokerProtectionPixelsHandler: EventMapping DataBrokerProtectionProfile? { wasFetchProfileCalled = true - return nil + return profile + } + + func setFetchedProfile(_ profile: DataBrokerProtectionProfile?) { + self.profile = profile } func deleteProfileData() { @@ -802,6 +807,7 @@ final class MockDatabase: DataBrokerProtectionRepository { lastParentBrokerWhereChildSitesWhereFetched = nil lastProfileQueryIdOnScanUpdatePreferredRunDate = nil brokerProfileQueryDataToReturn.removeAll() + profile = nil } }