diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDataManager.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDataManager.swift index 511bea7f80..9c1a7a4496 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDataManager.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDataManager.swift @@ -326,10 +326,10 @@ extension InMemoryDataCache: DBPUICommunicationDelegate { // 2. We map the brokers to the UI model .flatMap { dataBroker -> [DBPUIDataBroker] in var result: [DBPUIDataBroker] = [] - result.append(DBPUIDataBroker(name: dataBroker.name, url: dataBroker.url)) + result.append(DBPUIDataBroker(name: dataBroker.name, url: dataBroker.url, parentURL: dataBroker.parent)) for mirrorSite in dataBroker.mirrorSites { - result.append(DBPUIDataBroker(name: mirrorSite.name, url: mirrorSite.url)) + result.append(DBPUIDataBroker(name: mirrorSite.name, url: mirrorSite.url, parentURL: dataBroker.parent)) } return result } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift index 16419ed9a5..13379680a9 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift @@ -76,6 +76,15 @@ struct DBPUIUserProfileAddress: Codable { let zipCode: String? } +extension DBPUIUserProfileAddress { + init(addressCityState: AddressCityState) { + self.init(street: addressCityState.fullAddress, + city: addressCityState.city, + state: addressCityState.state, + zipCode: nil) + } +} + /// Message Object representing a user profile containing one or more names and addresses /// also contains the user profile's birth year struct DBPUIUserProfile: Codable { @@ -105,11 +114,13 @@ struct DBPUIDataBroker: Codable, Hashable { let name: String let url: String let date: Double? + let parentURL: String? - init(name: String, url: String, date: Double? = nil) { + init(name: String, url: String, date: Double? = nil, parentURL: String?) { self.name = name self.url = url self.date = date + self.parentURL = parentURL } func hash(into hasher: inout Hasher) { @@ -135,7 +146,72 @@ struct DBPUIDataBrokerProfileMatch: Codable { let addresses: [DBPUIUserProfileAddress] let alternativeNames: [String] let relatives: [String] - let date: Double? // Used in some methods to set the removedDate or found date + let foundDate: Double + let optOutSubmittedDate: Double? + let estimatedRemovalDate: Double? + let removedDate: Double? + let hasMatchingRecordOnParentBroker: Bool +} + +extension DBPUIDataBrokerProfileMatch { + init(optOutJobData: OptOutJobData, + dataBrokerName: String, + dataBrokerURL: String, + dataBrokerParentURL: String?, + parentBrokerOptOutJobData: [OptOutJobData]?) { + let extractedProfile = optOutJobData.extractedProfile + + /* + createdDate used to not exist in the DB, so in the migration we defaulted it to Unix Epoch zero (i.e. 1970) + If that's the case, we should rely on the events instead + We don't do that all the time since it's unnecssarily expensive trawling through events, and + this is involved in some already heavy endpoints + + optOutSubmittedDate also used to not exist, but instead defaults to nil + However, it could be nil simply because the opt out hasn't been submitted yet. So since we don't want to + look through events unneccesarily, we instead only look for it if the createdDate is 1970 + */ + var foundDate = optOutJobData.createdDate + var optOutSubmittedDate = optOutJobData.submittedSuccessfullyDate + if foundDate == Date(timeIntervalSince1970: 0) { + let foundEvents = optOutJobData.historyEvents.filter { $0.isMatchesFoundEvent() } + let firstFoundEvent = foundEvents.min(by: { $0.date < $1.date }) + if let firstFoundEventDate = firstFoundEvent?.date { + foundDate = firstFoundEventDate + } else { + assertionFailure("No matching MatchFound event for an extract profile found") + } + + let optOutSubmittedEvents = optOutJobData.historyEvents.filter { $0.type == .optOutRequested } + let firstOptOutEvent = optOutSubmittedEvents.min(by: { $0.date < $1.date }) + optOutSubmittedDate = firstOptOutEvent?.date + } + let estimatedRemovalDate = Calendar.current.date(byAdding: .day, value: 14, to: optOutSubmittedDate ?? foundDate) + + // Check for any matching records on the parent broker + let hasFoundParentMatch = parentBrokerOptOutJobData?.contains { parentOptOut in + extractedProfile.doesMatchExtractedProfile(parentOptOut.extractedProfile) + } ?? false + + self.init(dataBroker: DBPUIDataBroker(name: dataBrokerName, url: dataBrokerURL, parentURL: dataBrokerParentURL), + name: extractedProfile.fullName ?? "No name", + addresses: extractedProfile.addresses?.map {DBPUIUserProfileAddress(addressCityState: $0) } ?? [], + alternativeNames: extractedProfile.alternativeNames ?? [String](), + relatives: extractedProfile.relatives ?? [String](), + foundDate: foundDate.timeIntervalSince1970, + optOutSubmittedDate: optOutSubmittedDate?.timeIntervalSince1970, + estimatedRemovalDate: estimatedRemovalDate?.timeIntervalSince1970, + removedDate: extractedProfile.removedDate?.timeIntervalSince1970, + hasMatchingRecordOnParentBroker: hasFoundParentMatch) + } + + init(optOutJobData: OptOutJobData, dataBroker: DataBroker, parentBrokerOptOutJobData: [OptOutJobData]?) { + self.init(optOutJobData: optOutJobData, + dataBrokerName: dataBroker.name, + dataBrokerURL: dataBroker.url, + dataBrokerParentURL: dataBroker.parent, + parentBrokerOptOutJobData: parentBrokerOptOutJobData) + } } /// Protocol to represent a message that can be passed from the host to the UI @@ -156,6 +232,27 @@ struct DBPUIOptOutMatch: DBPUISendableMessage { let alternativeNames: [String] let addresses: [DBPUIUserProfileAddress] let date: Double + let foundDate: Double + let optOutSubmittedDate: Double? + let estimatedRemovalDate: Double? + let removedDate: Double? +} + +extension DBPUIOptOutMatch { + init?(profileMatch: DBPUIDataBrokerProfileMatch, matches: Int) { + guard let removedDate = profileMatch.removedDate else { return nil } + let dataBroker = profileMatch.dataBroker + self.init(dataBroker: dataBroker, + matches: matches, + name: profileMatch.name, + alternativeNames: profileMatch.alternativeNames, + addresses: profileMatch.addresses, + date: removedDate, + foundDate: profileMatch.foundDate, + optOutSubmittedDate: profileMatch.optOutSubmittedDate, + estimatedRemovalDate: profileMatch.estimatedRemovalDate, + removedDate: removedDate) + } } /// Data representing the initial scan progress diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift index 5bf40900ae..3412e94def 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift @@ -56,7 +56,7 @@ struct ExtractProfileSelectors: Codable, Sendable { } } -struct AddressCityState: Codable { +struct AddressCityState: Codable, Hashable { let city: String let state: String @@ -166,6 +166,39 @@ struct ExtractedProfile: Codable, Sendable { identifier: self.identifier ) } + + /* + Matching records are: + 1/ Completely identical records (same name, addresses, ages, etc) + 2/ Records that overlap completely (record A has all the data of record B, but might have + extra information as well (e.g. an extra address, a middle name where record B doesn't) + I.e. B is a subset of A, or vice versa + However, we ignore some of the properties + So, basically age == age, we ignore phone numbers and email, and then everything else one should be a subset of the other + */ + func doesMatchExtractedProfile(_ extractedProfile: ExtractedProfile) -> Bool { + if age != extractedProfile.age { + return false + } + + if name != extractedProfile.name { + return false + } + + if !(alternativeNames ?? []).isASubSetOrSuperSetOf(extractedProfile.alternativeNames ?? []) { + return false + } + + if !(addresses ?? []).isASubSetOrSuperSetOf(extractedProfile.addresses ?? []) { + return false + } + + if !(relatives ?? []).isASubSetOrSuperSetOf(extractedProfile.relatives ?? []) { + return false + } + + return true + } } extension ExtractedProfile: Equatable { @@ -173,3 +206,11 @@ extension ExtractedProfile: Equatable { lhs.name == rhs.name } } + +private extension Sequence where Element: Hashable { + func isASubSetOrSuperSetOf(_ sequence: Settable) -> Bool where Settable: Sequence, Element == Settable.Element { + let setA = Set(self) + let setB = Set(sequence) + return setA.isSubset(of: setB) || setB.isSubset(of: setA) + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/HistoryEvent.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/HistoryEvent.swift index c783ea4422..37b364d58f 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/HistoryEvent.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/HistoryEvent.swift @@ -69,4 +69,13 @@ public struct HistoryEvent: Identifiable, Sendable { return false } } + + func isMatchesFoundEvent() -> Bool { + switch type { + case .matchesFound: + return true + default: + return false + } + } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift index 8ee484f93a..1438e0b0dc 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift @@ -78,7 +78,7 @@ struct DBPUICommunicationLayer: Subfeature { weak var delegate: DBPUICommunicationDelegate? private enum Constants { - static let version = 5 + static let version = 6 } internal init(webURLSettings: DataBrokerProtectionWebUIURLSettingsRepresentable, diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift index 5a7bbf5571..d59fbd1f38 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift @@ -22,36 +22,6 @@ import os.log struct MapperToUI { - func mapToUI(_ dataBroker: DataBroker, extractedProfile: ExtractedProfile) -> DBPUIDataBrokerProfileMatch { - DBPUIDataBrokerProfileMatch( - dataBroker: mapToUI(dataBroker), - name: extractedProfile.fullName ?? "No name", - addresses: extractedProfile.addresses?.map(mapToUI) ?? [], - alternativeNames: extractedProfile.alternativeNames ?? [String](), - relatives: extractedProfile.relatives ?? [String](), - date: extractedProfile.removedDate?.timeIntervalSince1970 - ) - } - - func mapToUI(_ dataBrokerName: String, databrokerURL: String, extractedProfile: ExtractedProfile) -> DBPUIDataBrokerProfileMatch { - DBPUIDataBrokerProfileMatch( - dataBroker: DBPUIDataBroker(name: dataBrokerName, url: databrokerURL), - name: extractedProfile.fullName ?? "No name", - addresses: extractedProfile.addresses?.map(mapToUI) ?? [], - alternativeNames: extractedProfile.alternativeNames ?? [String](), - relatives: extractedProfile.relatives ?? [String](), - date: extractedProfile.removedDate?.timeIntervalSince1970 - ) - } - - func mapToUI(_ dataBroker: DataBroker) -> DBPUIDataBroker { - DBPUIDataBroker(name: dataBroker.name, url: dataBroker.url) - } - - func mapToUI(_ address: AddressCityState) -> DBPUIUserProfileAddress { - DBPUIUserProfileAddress(street: address.fullAddress, city: address.city, state: address.state, zipCode: nil) - } - func initialScanState(_ brokerProfileQueryData: [BrokerProfileQueryData]) -> DBPUIInitialScanState { let withoutDeprecated = brokerProfileQueryData.filter { !$0.profileQuery.deprecated } @@ -74,21 +44,39 @@ struct MapperToUI { totalScans: totalScans, scannedBrokers: partiallyScannedBrokers) - let matches = mapMatchesToUI(brokerProfileQueryData) + let matches = mapMatchesToUI(withoutDeprecated) return .init(resultsFound: matches, scanProgress: scanProgress) } private func mapMatchesToUI(_ brokerProfileQueryData: [BrokerProfileQueryData]) -> [DBPUIDataBrokerProfileMatch] { + + // Used to find opt outs on the parent + let brokerURLsToQueryData = Dictionary(grouping: brokerProfileQueryData, by: { $0.dataBroker.url }) + return brokerProfileQueryData.flatMap { var profiles = [DBPUIDataBrokerProfileMatch]() - for extractedProfile in $0.extractedProfiles where !$0.profileQuery.deprecated { - profiles.append(mapToUI($0.dataBroker, extractedProfile: extractedProfile)) + for optOutJobData in $0.optOutJobData { + let dataBroker = $0.dataBroker + + var parentBrokerOptOutJobData: [OptOutJobData]? + if let parent = dataBroker.parent, + let parentsQueryData = brokerURLsToQueryData[parent] { + parentBrokerOptOutJobData = parentsQueryData.flatMap { $0.optOutJobData } + } + + profiles.append(DBPUIDataBrokerProfileMatch(optOutJobData: optOutJobData, + dataBroker: dataBroker, + parentBrokerOptOutJobData: parentBrokerOptOutJobData)) - if !$0.dataBroker.mirrorSites.isEmpty { - let mirrorSitesMatches = $0.dataBroker.mirrorSites.compactMap { mirrorSite in + if !dataBroker.mirrorSites.isEmpty { + let mirrorSitesMatches = dataBroker.mirrorSites.compactMap { mirrorSite in if mirrorSite.shouldWeIncludeMirrorSite() { - return mapToUI(mirrorSite.name, databrokerURL: mirrorSite.url, extractedProfile: extractedProfile) + return DBPUIDataBrokerProfileMatch(optOutJobData: optOutJobData, + dataBrokerName: mirrorSite.name, + dataBrokerURL: mirrorSite.url, + dataBrokerParentURL: dataBroker.parent, + parentBrokerOptOutJobData: parentBrokerOptOutJobData) } return nil @@ -108,12 +96,24 @@ struct MapperToUI { let scansThatRanAtLeastOnce = brokerProfileQueryData.flatMap { $0.sitesScanned } let sitesScanned = Dictionary(grouping: scansThatRanAtLeastOnce, by: { $0 }).count + // Used to find opt outs on the parent + let brokerURLsToQueryData = Dictionary(grouping: brokerProfileQueryData, by: { $0.dataBroker.url }) + brokerProfileQueryData.forEach { let dataBroker = $0.dataBroker let scanJob = $0.scanJobData for optOutJob in $0.optOutJobData { let extractedProfile = optOutJob.extractedProfile - let profileMatch = mapToUI(dataBroker, extractedProfile: extractedProfile) + + var parentBrokerOptOutJobData: [OptOutJobData]? + if let parent = $0.dataBroker.parent, + let parentsQueryData = brokerURLsToQueryData[parent] { + parentBrokerOptOutJobData = parentsQueryData.flatMap { $0.optOutJobData } + } + + let profileMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOutJob, + dataBroker: dataBroker, + parentBrokerOptOutJobData: parentBrokerOptOutJobData) if extractedProfile.removedDate == nil { inProgressOptOuts.append(profileMatch) @@ -123,7 +123,11 @@ struct MapperToUI { if let closestMatchesFoundEvent = scanJob.closestMatchesFoundEvent() { for mirrorSite in dataBroker.mirrorSites where mirrorSite.shouldWeIncludeMirrorSite(for: closestMatchesFoundEvent.date) { - let mirrorSiteMatch = mapToUI(mirrorSite.name, databrokerURL: mirrorSite.url, extractedProfile: extractedProfile) + let mirrorSiteMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOutJob, + dataBrokerName: mirrorSite.name, + dataBrokerURL: mirrorSite.url, + dataBrokerParentURL: dataBroker.parent, + parentBrokerOptOutJobData: parentBrokerOptOutJobData) if let extractedProfileRemovedDate = extractedProfile.removedDate, mirrorSite.shouldWeIncludeMirrorSite(for: extractedProfileRemovedDate) { @@ -137,15 +141,9 @@ struct MapperToUI { } let completedOptOutsDictionary = Dictionary(grouping: removedProfiles, by: { $0.dataBroker }) - let completedOptOuts: [DBPUIOptOutMatch] = completedOptOutsDictionary.compactMap { (key: DBPUIDataBroker, value: [DBPUIDataBrokerProfileMatch]) in + let completedOptOuts: [DBPUIOptOutMatch] = completedOptOutsDictionary.compactMap { (_, value: [DBPUIDataBrokerProfileMatch]) in value.compactMap { match in - guard let removedDate = match.date else { return nil } - return DBPUIOptOutMatch(dataBroker: key, - matches: value.count, - name: match.name, - alternativeNames: match.alternativeNames, - addresses: match.addresses, - date: removedDate) + return DBPUIOptOutMatch(profileMatch: match, matches: value.count) } }.flatMap { $0 } @@ -174,10 +172,16 @@ struct MapperToUI { } .flatMap { var brokers = [DBPUIDataBroker]() - brokers.append(DBPUIDataBroker(name: $0.dataBroker.name, url: $0.dataBroker.url, date: $0.scanJobData.lastRunDate!.timeIntervalSince1970)) + brokers.append(DBPUIDataBroker(name: $0.dataBroker.name, + url: $0.dataBroker.url, + date: $0.scanJobData.lastRunDate!.timeIntervalSince1970, + parentURL: $0.dataBroker.parent)) for mirrorSite in $0.dataBroker.mirrorSites where mirrorSite.addedAt < $0.scanJobData.lastRunDate! { - brokers.append(DBPUIDataBroker(name: mirrorSite.name, url: mirrorSite.url, date: $0.scanJobData.lastRunDate!.timeIntervalSince1970)) + brokers.append(DBPUIDataBroker(name: mirrorSite.name, + url: mirrorSite.url, + date: $0.scanJobData.lastRunDate!.timeIntervalSince1970, + parentURL: $0.dataBroker.parent)) } return brokers @@ -204,15 +208,24 @@ struct MapperToUI { } .flatMap { var brokers = [DBPUIDataBroker]() - brokers.append(DBPUIDataBroker(name: $0.dataBroker.name, url: $0.dataBroker.url, date: $0.scanJobData.preferredRunDate!.timeIntervalSince1970)) + brokers.append(DBPUIDataBroker(name: $0.dataBroker.name, + url: $0.dataBroker.url, + date: $0.scanJobData.preferredRunDate!.timeIntervalSince1970, + parentURL: $0.dataBroker.parent)) for mirrorSite in $0.dataBroker.mirrorSites { if let removedDate = mirrorSite.removedAt { if removedDate > $0.scanJobData.preferredRunDate! { - brokers.append(DBPUIDataBroker(name: mirrorSite.name, url: mirrorSite.url, date: $0.scanJobData.preferredRunDate!.timeIntervalSince1970)) + brokers.append(DBPUIDataBroker(name: mirrorSite.name, + url: mirrorSite.url, + date: $0.scanJobData.preferredRunDate!.timeIntervalSince1970, + parentURL: $0.dataBroker.parent)) } } else { - brokers.append(DBPUIDataBroker(name: mirrorSite.name, url: mirrorSite.url, date: $0.scanJobData.preferredRunDate!.timeIntervalSince1970)) + brokers.append(DBPUIDataBroker(name: mirrorSite.name, + url: mirrorSite.url, + date: $0.scanJobData.preferredRunDate!.timeIntervalSince1970, + parentURL: $0.dataBroker.parent)) } } diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift new file mode 100644 index 0000000000..68b71f4fdd --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift @@ -0,0 +1,233 @@ +// +// DBPUICommunicationModelTests.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 Foundation +@testable import DataBrokerProtection + +final class DBPUICommunicationModelTests: XCTestCase { + + func testProfileMatchInit_whenCreatedDateIsNotDefault_thenResultingProfileMatchDatesAreBothBasedOnOptOutJobDataDates() { + + // Given + let extractedProfile = ExtractedProfile.mockWithRemovedDate + + let foundEventDate = Calendar.current.date(byAdding: .day, value: -20, to: Date.now)! + let submittedEventDate = Calendar.current.date(byAdding: .day, value: -18, to: Date.now)! + let historyEvents = [ + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .matchesFound(count: 1), date: foundEventDate), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .optOutRequested, date: submittedEventDate) + ] + + let createdDate = Calendar.current.date(byAdding: .day, value: -14, to: Date.now)! + let submittedDate = Calendar.current.date(byAdding: .day, value: -7, to: Date.now)! + let optOut = OptOutJobData.mock(with: extractedProfile, + historyEvents: historyEvents, + createdDate: createdDate, + submittedSuccessfullyDate: submittedDate) + + // When + let profileMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOut, + dataBrokerName: "doesn't matter for the test", + dataBrokerURL: "see above", + dataBrokerParentURL: "whatever", + parentBrokerOptOutJobData: nil) + + // Then + XCTAssertEqual(profileMatch.foundDate, createdDate.timeIntervalSince1970) + XCTAssertEqual(profileMatch.optOutSubmittedDate, submittedDate.timeIntervalSince1970) + } + + func testProfileMatchInit_whenCreatedDateIsDefault_thenResultingProfileMatchDatesAreBothBasedOnEventDates() { + + // Given + let extractedProfile = ExtractedProfile.mockWithRemovedDate + + let foundEventDate = Calendar.current.date(byAdding: .day, value: -20, to: Date.now)! + let submittedEventDate = Calendar.current.date(byAdding: .day, value: -18, to: Date.now)! + let historyEvents = [ + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .matchesFound(count: 1), date: foundEventDate), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .optOutRequested, date: submittedEventDate) + ] + + let createdDate = Date(timeIntervalSince1970: 0) + let submittedDate = Calendar.current.date(byAdding: .day, value: -7, to: Date.now)! + let optOut = OptOutJobData.mock(with: extractedProfile, + historyEvents: historyEvents, + createdDate: createdDate, + submittedSuccessfullyDate: submittedDate) + + // When + let profileMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOut, + dataBrokerName: "doesn't matter for the test", + dataBrokerURL: "see above", + dataBrokerParentURL: "whatever", + parentBrokerOptOutJobData: nil) + + // Then + XCTAssertEqual(profileMatch.foundDate, foundEventDate.timeIntervalSince1970) + XCTAssertEqual(profileMatch.optOutSubmittedDate, submittedEventDate.timeIntervalSince1970) + } + + func testProfileMatchInit_whenCreatedDateIsDefaultAndThereAreMultipleEventsOfTheSameType_thenResultingProfileMatchDatesAreBothBasedOnFirstEventDates() { + + // Given + let extractedProfile = ExtractedProfile.mockWithRemovedDate + + let foundEventDate1 = Calendar.current.date(byAdding: .day, value: -20, to: Date.now)! + let foundEventDate2 = Calendar.current.date(byAdding: .day, value: -21, to: Date.now)! + let foundEventDate3 = Calendar.current.date(byAdding: .day, value: -19, to: Date.now)! + let submittedEventDate1 = Calendar.current.date(byAdding: .day, value: -18, to: Date.now)! + let submittedEventDate2 = Calendar.current.date(byAdding: .day, value: -19, to: Date.now)! + let submittedEventDate3 = Calendar.current.date(byAdding: .day, value: -17, to: Date.now)! + let historyEvents = [ + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .matchesFound(count: 1), date: foundEventDate1), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .matchesFound(count: 1), date: foundEventDate2), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .matchesFound(count: 1), date: foundEventDate3), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .optOutRequested, date: submittedEventDate1), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .optOutRequested, date: submittedEventDate2), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .optOutRequested, date: submittedEventDate3) + ] + + let createdDate = Date(timeIntervalSince1970: 0) + let submittedDate = Calendar.current.date(byAdding: .day, value: -7, to: Date.now)! + let optOut = OptOutJobData.mock(with: extractedProfile, + historyEvents: historyEvents, + createdDate: createdDate, + submittedSuccessfullyDate: submittedDate) + + // When + let profileMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOut, + dataBrokerName: "doesn't matter for the test", + dataBrokerURL: "see above", + dataBrokerParentURL: "whatever", + parentBrokerOptOutJobData: nil) + + // Then + XCTAssertEqual(profileMatch.foundDate, foundEventDate2.timeIntervalSince1970) + XCTAssertEqual(profileMatch.optOutSubmittedDate, submittedEventDate2.timeIntervalSince1970) + } + + /* + test cases + one exact matching parent + one exact matching parent mixed in the array (probs can combnie with above + no match + partial match + */ + + func testProfileMatchInit_whenThereIsExactParentMatch_thenHasMatchingRecordOnParentBrokerIsTrue() { + + // Given + let extractedProfile = ExtractedProfile.mockWithName("Steve Jones", age: "20", addresses: [AddressCityState(city: "New York", state: "NY")]) + let parentProfile = ExtractedProfile.mockWithName("Steve Jones", age: "20", addresses: [AddressCityState(city: "New York", state: "NY")]) + + let optOut = OptOutJobData.mock(with: extractedProfile, + historyEvents: []) + let parentOptOut = OptOutJobData.mock(with: parentProfile, + historyEvents: []) + + // When + let profileMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOut, + dataBrokerName: "doesn't matter for the test", + dataBrokerURL: "see above", + dataBrokerParentURL: "whatever", + parentBrokerOptOutJobData: [parentOptOut]) + + // Then + XCTAssertTrue(profileMatch.hasMatchingRecordOnParentBroker) + } + + func testProfileMatchInit_whenThereAreMultipleNonMatchingProfilesAndAnExactParentMatch_thenHasMatchingRecordOnParentBrokerIsTrue() { + + // Given + let extractedProfile = ExtractedProfile.mockWithName("Steve Jones", age: "20", addresses: [AddressCityState(city: "New York", state: "NY")]) + let parentProfileMatching = ExtractedProfile.mockWithName("Steve Jones", age: "20", addresses: [AddressCityState(city: "New York", state: "NY")]) + let parentProfileNonmatching1 = ExtractedProfile.mockWithName("Steve Jones", age: "30", addresses: [AddressCityState(city: "New York", state: "NY")]) + let parentProfileNonmatching2 = ExtractedProfile.mockWithName("Jamie Jones", age: "20", addresses: [AddressCityState(city: "New York", state: "NY")]) + + let optOut = OptOutJobData.mock(with: extractedProfile, + historyEvents: []) + let parentOptOutMatching = OptOutJobData.mock(with: parentProfileMatching, + historyEvents: []) + let parentOptOutNonmatching1 = OptOutJobData.mock(with: parentProfileNonmatching1, + historyEvents: []) + let parentOptOutNonmatching2 = OptOutJobData.mock(with: parentProfileNonmatching2, + historyEvents: []) + + // When + let profileMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOut, + dataBrokerName: "doesn't matter for the test", + dataBrokerURL: "see above", + dataBrokerParentURL: "whatever", + parentBrokerOptOutJobData: [parentOptOutNonmatching1, + parentOptOutMatching, + parentOptOutNonmatching2]) + + // Then + XCTAssertTrue(profileMatch.hasMatchingRecordOnParentBroker) + } + + func testProfileMatchInit_whenThereIsNoParentMatch_thenHasMatchingRecordOnParentBrokerIsFalse() { + + // Given + let extractedProfile = ExtractedProfile.mockWithName("Steve Jones", age: "20", addresses: [AddressCityState(city: "New York", state: "NY")]) + let parentProfileNonmatching1 = ExtractedProfile.mockWithName("Steve Jones", age: "30", addresses: [AddressCityState(city: "New York", state: "NY")]) + let parentProfileNonmatching2 = ExtractedProfile.mockWithName("Jamie Jones", age: "20", addresses: [AddressCityState(city: "New York", state: "NY")]) + + let optOut = OptOutJobData.mock(with: extractedProfile, + historyEvents: []) + let parentOptOutNonmatching1 = OptOutJobData.mock(with: parentProfileNonmatching1, + historyEvents: []) + let parentOptOutNonmatching2 = OptOutJobData.mock(with: parentProfileNonmatching2, + historyEvents: []) + + // When + let profileMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOut, + dataBrokerName: "doesn't matter for the test", + dataBrokerURL: "see above", + dataBrokerParentURL: "whatever", + parentBrokerOptOutJobData: [parentOptOutNonmatching1, + parentOptOutNonmatching2]) + + // Then + XCTAssertFalse(profileMatch.hasMatchingRecordOnParentBroker) + } + + func testProfileMatchInit_whenThereIsANonExactParentMatch_thenHasMatchingRecordOnParentBrokerIsTrue() { + + // Given + let extractedProfile = ExtractedProfile.mockWithName("Steve Jones", age: "20", addresses: [AddressCityState(city: "New York", state: "NY")]) + let parentProfile = ExtractedProfile.mockWithName("Steve Jones", age: "20", addresses: [AddressCityState(city: "New York", state: "NY"), AddressCityState(city: "Atlanta", state: "GA")]) + + let optOut = OptOutJobData.mock(with: extractedProfile, + historyEvents: []) + let parentOptOut = OptOutJobData.mock(with: parentProfile, + historyEvents: []) + + // When + let profileMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOut, + dataBrokerName: "doesn't matter for the test", + dataBrokerURL: "see above", + dataBrokerParentURL: "whatever", + parentBrokerOptOutJobData: [parentOptOut]) + + // Then + XCTAssertTrue(profileMatch.hasMatchingRecordOnParentBroker) + } +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift index af88800ccb..b41f802d47 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift @@ -1074,6 +1074,10 @@ extension ExtractedProfile { static func mockWithRemoveDate(_ date: Date) -> ExtractedProfile { ExtractedProfile(id: 1, name: "Some name", profileUrl: "someURL", removedDate: date, identifier: "someURL") } + + static func mockWithName(_ name: String, alternativeNames: [String]? = nil, age: String, addresses: [AddressCityState], relatives: [String]? = nil) -> ExtractedProfile { + ExtractedProfile(id: 1, name: name, alternativeNames: [], addressFull: nil, addresses: addresses, phoneNumbers: nil, relatives: nil, profileUrl: "someUrl", age: age, identifier: "someUrl") + } } extension AttemptInformation { diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/ExtractedProfileTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/ExtractedProfileTests.swift index 2711b2de6c..0e7f1d14ab 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/ExtractedProfileTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/ExtractedProfileTests.swift @@ -61,4 +61,159 @@ final class ExtractedProfileTests: XCTestCase { XCTAssertEqual(sut.age, "52") } + + // MARK: - Test matching logic + + func testDoesMatchExtractedProfile_whenThereAnExactMatch_thenDoesMatchExtractedProfileIsTrue() { + + // Given + let extractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: ["Steven Jones", + "Steven M Jones"], + age: "20", + addresses: [AddressCityState(city: "New York", state: "NY"), + AddressCityState(city: "Miami", state: "FL")], + relatives: ["Steven Jones Jr", + "Steven Jones Sr", + "Steven Jones Staff", + "Steven Jones Principle"]) + let matchingExtractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: ["Steven Jones", + "Steven M Jones"], + age: "20", + addresses: [AddressCityState(city: "New York", state: "NY"), + AddressCityState(city: "Miami", state: "FL")], + relatives: ["Steven Jones Jr", + "Steven Jones Sr", + "Steven Jones Staff", + "Steven Jones Principle"]) + + // Then + XCTAssertTrue(extractedProfile.doesMatchExtractedProfile(matchingExtractedProfile)) + } + + func testDoesMatchExtractedProfile_whenThereIsANonMatch_thenDoesMatchExtractedProfileIsFalse() { + + // Given + let extractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: ["Steven Jones", + "Steven M Jones"], + age: "20", + addresses: [AddressCityState(city: "New York", state: "NY"), + AddressCityState(city: "Miami", state: "FL")], + relatives: ["Steven Jones Jr", + "Steven Jones Sr", + "Steven Jones Staff", + "Steven Jones Principle"]) + let nonmatchingExtractedProfile = ExtractedProfile.mockWithName("James Smith", + alternativeNames: ["James Jameson and The Legion of Doom"], + age: "57", + addresses: [AddressCityState(city: "Blackpool", state: "NY"), + AddressCityState(city: "Underneath a Volcano", state: "FL")], + relatives: ["Beelzebub", + "Barney the Dinosaur"]) + + // Then + XCTAssertFalse(extractedProfile.doesMatchExtractedProfile(nonmatchingExtractedProfile)) + } + + func testDoesMatchExtractedProfile_whenThereAPartialMatch_thenDoesMatchExtractedProfileIsFalse() { + + // Given + let extractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: ["Steven Jones", + "Steven M Jones"], + age: "20", + addresses: [AddressCityState(city: "New York", state: "NY"), + AddressCityState(city: "Miami", state: "FL")], + relatives: ["Steven Jones Jr", + "Steven Jones Sr", + "Steven Jones Staff", + "Steven Jones Principle"]) + let nonmatchingExtractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: ["Steven Jones", + "Steven M Jones"], + age: "30", + addresses: [AddressCityState(city: "New York", state: "NY"), + AddressCityState(city: "Miami", state: "FL")], + relatives: ["Steven Jones Jr", + "Steven Jones Sr", + "Steven Jones Staff", + "Steven Jones Principle"]) + + // Then + XCTAssertFalse(extractedProfile.doesMatchExtractedProfile(nonmatchingExtractedProfile)) + } + + func testDoesMatchExtractedProfile_whenThereASubsetMatch_thenDoesMatchExtractedProfileIsTrue() { + + // Given + let extractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: ["Steven Jones", + "Steven M Jones"], + age: "20", + addresses: [AddressCityState(city: "New York", state: "NY"), + AddressCityState(city: "Miami", state: "FL")], + relatives: ["Steven Jones Jr", + "Steven Jones Sr", + "Steven Jones Staff", + "Steven Jones Principle"]) + let matchingExtractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: ["Steven Jones"], + age: "20", + addresses: [AddressCityState(city: "Miami", state: "FL")], + relatives: []) + + // Then + XCTAssertTrue(extractedProfile.doesMatchExtractedProfile(matchingExtractedProfile)) + } + + func testDoesMatchExtractedProfile_whenThereIsASupersetMatch_thenDoesMatchExtractedProfileIsTrue() { + + // Given + let extractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: [], + age: "20", + addresses: [AddressCityState(city: "Miami", state: "FL")], + relatives: ["Steven Jones Staff", + "Steven Jones Principle"]) + let matchingExtractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: ["Steven Jones", + "Steven M Jones"], + age: "20", + addresses: [AddressCityState(city: "New York", state: "NY"), + AddressCityState(city: "Miami", state: "FL")], + relatives: ["Steven Jones Jr", + "Steven Jones Sr", + "Steven Jones Staff", + "Steven Jones Principle"]) + + // Then + XCTAssertTrue(extractedProfile.doesMatchExtractedProfile(matchingExtractedProfile)) + } + + // When some fields are subsets, and some are supersets + func testDoesMatchExtractedProfile_whenThereAMixedSubsetSupersetMatch_thenDoesMatchExtractedProfileIsTrue() { + + // Given + let extractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: [], + age: "20", + addresses: [], + relatives: ["Steven Jones Jr", + "Steven Jones Sr", + "Steven Jones Staff", + "Steven Jones Principle"]) + let matchingExtractedProfile = ExtractedProfile.mockWithName("Steve Jones", + alternativeNames: ["Steven Jones", + "Steven M Jones"], + age: "20", + addresses: [AddressCityState(city: "New York", state: "NY"), + AddressCityState(city: "Miami", state: "FL")], + relatives: ["Steven Jones Jr", + "Steven Jones Principle"]) + + // Then + XCTAssertTrue(extractedProfile.doesMatchExtractedProfile(matchingExtractedProfile)) + } } diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift index 7bf578e3f8..91341e087b 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift @@ -1147,6 +1147,13 @@ extension OptOutJobData { .init(brokerId: 1, profileQueryId: 1, createdDate: Date(), historyEvents: historyEvents, extractedProfile: extractedProfile) } + static func mock(with extractedProfile: ExtractedProfile, + historyEvents: [HistoryEvent] = [HistoryEvent](), + createdDate: Date, + submittedSuccessfullyDate: Date?) -> OptOutJobData { + .init(brokerId: 1, profileQueryId: 1, createdDate: createdDate, historyEvents: historyEvents, submittedSuccessfullyDate: submittedSuccessfullyDate, extractedProfile: extractedProfile) + } + static func mock(with type: HistoryEvent.EventType, submittedDate: Date?, sevenDaysConfirmationPixelFired: Bool,