diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift index 68e5f19d0e..5ee507e390 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift @@ -146,11 +146,93 @@ struct DBPUIDataBrokerList: DBPUISendableMessage { let dataBrokers: [DBPUIDataBroker] } -/// Message Object representing a requested change to the user profile's brith year +/// Message Object representing a requested change to the user profile's birth year struct DBPUIBirthYear: Codable { let year: Int } +/// Message Object representing a supported timeline event +/// https://app.asana.com/0/481882893211075/1208663928051302/f +struct DBPUITimelineEvent: Codable { + enum EventType: String, Codable { + case recordFound = "record-found" + case recordReappeared = "record-reappeared" + case optOutSubmitted = "opt-out-submitted" + case estimatedRemoval = "estimated-removal" + case removed = "removed" + } + + let type: EventType + let date: Double + + var eventDate: Date { + Date(timeIntervalSince1970: date) + } + + init?(type: EventType, date: Date?) { + guard let date else { return nil } + self.type = type + self.date = date.timeIntervalSince1970 + } +} + +extension DBPUITimelineEvent { + init?(foundDate: Date?) { + self.init(type: .recordFound, date: foundDate) + } + + init?(reappearedDate: Date?) { + self.init(type: .recordReappeared, date: reappearedDate) + } + + init?(optOutSubmittedDate: Date?) { + self.init(type: .optOutSubmitted, date: optOutSubmittedDate) + } + + init?(estimatedRemovalDate: Date?) { + self.init(type: .estimatedRemoval, date: estimatedRemovalDate) + } + + init?(removedDate: Date?) { + self.init(type: .removed, date: removedDate) + } + + static func from(historyEvents: [HistoryEvent], removedDate: Date?) -> [DBPUITimelineEvent] { + var timelineEvents = historyEvents.compactMap { event in + switch event.type { + case .matchesFound: + return DBPUITimelineEvent(foundDate: event.date) + case .reAppearence: + return DBPUITimelineEvent(reappearedDate: event.date) + case .optOutRequested: + return DBPUITimelineEvent(optOutSubmittedDate: event.date) + default: + return nil + } + } + + let mostRecentFoundEvent = timelineEvents.filter({ $0.type == .recordFound || $0.type == .recordReappeared }).max(by: <) + let mostRecentOptOutSubmittedEvent = timelineEvents.filter({ $0.type == .optOutSubmitted }).max(by: <) + + if let optOutSubmittedDate = (mostRecentOptOutSubmittedEvent ?? mostRecentFoundEvent)?.eventDate, + let estimatedRemovalEvent = DBPUITimelineEvent(estimatedRemovalDate: Calendar.current.date(byAdding: .day, value: 14, to: optOutSubmittedDate)) { + timelineEvents.append(estimatedRemovalEvent) + } + + if let removedEvent = DBPUITimelineEvent(removedDate: removedDate) { + timelineEvents.append(removedEvent) + } + + return timelineEvents + } +} + +extension DBPUITimelineEvent: Comparable { + static func < (lhs: DBPUITimelineEvent, rhs: DBPUITimelineEvent) -> Bool { + lhs.date < rhs.date + } +} + /// Message object containing information related to a profile match on a data broker /// The message contains the data broker on which the profile was found and the names /// and addresses that were matched @@ -160,11 +242,24 @@ struct DBPUIDataBrokerProfileMatch: Codable { let addresses: [DBPUIUserProfileAddress] let alternativeNames: [String] let relatives: [String] - let foundDate: Double - let optOutSubmittedDate: Double? - let estimatedRemovalDate: Double? - let removedDate: Double? + let timelineEvents: [DBPUITimelineEvent] let hasMatchingRecordOnParentBroker: Bool + + init(dataBroker: DBPUIDataBroker, + name: String, + addresses: [DBPUIUserProfileAddress], + alternativeNames: [String], + relatives: [String], + timelineEvents: [DBPUITimelineEvent], + hasMatchingRecordOnParentBroker: Bool) { + self.dataBroker = dataBroker + self.name = name + self.addresses = addresses + self.alternativeNames = alternativeNames + self.relatives = relatives + self.timelineEvents = timelineEvents.sorted(by: <) + self.hasMatchingRecordOnParentBroker = hasMatchingRecordOnParentBroker + } } extension DBPUIDataBrokerProfileMatch { @@ -175,33 +270,7 @@ extension DBPUIDataBrokerProfileMatch { parentBrokerOptOutJobData: [OptOutJobData]?, optOutUrl: String) { 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) + let timelineEvents = DBPUITimelineEvent.from(historyEvents: optOutJobData.historyEvents, removedDate: extractedProfile.removedDate) // Check for any matching records on the parent broker let hasFoundParentMatch = parentBrokerOptOutJobData?.contains { parentOptOut in @@ -213,10 +282,7 @@ extension DBPUIDataBrokerProfileMatch { 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, + timelineEvents: timelineEvents, hasMatchingRecordOnParentBroker: hasFoundParentMatch) } @@ -301,15 +367,12 @@ struct DBPUIOptOutMatch: DBPUISendableMessage { let alternativeNames: [String] let addresses: [DBPUIUserProfileAddress] let date: Double - let foundDate: Double - let optOutSubmittedDate: Double? - let estimatedRemovalDate: Double? - let removedDate: Double? + let timelineEvents: [DBPUITimelineEvent] } extension DBPUIOptOutMatch { init?(profileMatch: DBPUIDataBrokerProfileMatch, matches: Int) { - guard let removedDate = profileMatch.removedDate else { return nil } + guard let removedDate = profileMatch.timelineEvents.first(where: { $0.type == .removed })?.date else { return nil } let dataBroker = profileMatch.dataBroker self.init(dataBroker: dataBroker, matches: matches, @@ -317,10 +380,7 @@ extension DBPUIOptOutMatch { alternativeNames: profileMatch.alternativeNames, addresses: profileMatch.addresses, date: removedDate, - foundDate: profileMatch.foundDate, - optOutSubmittedDate: profileMatch.optOutSubmittedDate, - estimatedRemovalDate: profileMatch.estimatedRemovalDate, - removedDate: removedDate) + timelineEvents: profileMatch.timelineEvents) } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift index 5992962044..c0f16a4316 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift @@ -79,7 +79,7 @@ struct DBPUICommunicationLayer: Subfeature { weak var delegate: DBPUICommunicationDelegate? private enum Constants { - static let version = 8 + static let version = 9 } internal init(webURLSettings: DataBrokerProtectionWebUIURLSettingsRepresentable, diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift index aff311ce82..97ed62ca79 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift @@ -22,39 +22,110 @@ import Foundation final class DBPUICommunicationModelTests: XCTestCase { - func testProfileMatchInit_whenCreatedDateIsNotDefault_thenResultingProfileMatchDatesAreBothBasedOnOptOutJobDataDates() { + func testProfileMatch_whenInitWithEmptyHistoryEventsAndNoRemovedDate_thenTimelineEventsAreAlsoEmpty() { + // Given + let historyEvents = [HistoryEvent]() + + let mockDataBroker = DBPUIDataBroker(name: "some broker", url: "broker.com", parentURL: nil, optOutUrl: "some url") + + // When + let timelineEvents = DBPUITimelineEvent.from(historyEvents: historyEvents, removedDate: nil) + let profileMatch = DBPUIDataBrokerProfileMatch(dataBroker: mockDataBroker, + name: "some profile", + addresses: [], + alternativeNames: [], + relatives: [], + timelineEvents: timelineEvents, + hasMatchingRecordOnParentBroker: true) + // Then + XCTAssertEqual(profileMatch.timelineEvents, []) + } + + func testProfileMatch_whenInit_thenTimelineEventsAreFilteredAndSortedChronologically() { // Given - let extractedProfile = ExtractedProfile.mockWithRemovedDate + let historyEvents = [ + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .scanStarted, date: Date(timeIntervalSince1970: 0)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .noMatchFound, date: Date(timeIntervalSince1970: 50)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .matchesFound(count: 2), date: Date(timeIntervalSince1970: 100)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 2, type: .reAppearence, date: Date(timeIntervalSince1970: 2500)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 2, type: .optOutStarted, date: Date(timeIntervalSince1970: 2000)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 2, type: .optOutRequested, date: Date(timeIntervalSince1970: 2000)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 1, type: .optOutStarted, date: Date(timeIntervalSince1970: 1000)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 1, type: .optOutRequested, date: Date(timeIntervalSince1970: 1000)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 1, type: .reAppearence, date: Date(timeIntervalSince1970: 1500)), + ] - 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 expectedTimelineEvents = [ + DBPUITimelineEvent(foundDate: Date(timeIntervalSince1970: 100)), + DBPUITimelineEvent(optOutSubmittedDate: Date(timeIntervalSince1970: 1000)), + DBPUITimelineEvent(reappearedDate: Date(timeIntervalSince1970: 1500)), + DBPUITimelineEvent(optOutSubmittedDate: Date(timeIntervalSince1970: 2000)), + DBPUITimelineEvent(reappearedDate: Date(timeIntervalSince1970: 2500)), + DBPUITimelineEvent(removedDate: Date(timeIntervalSince1970: 5000)), + DBPUITimelineEvent(estimatedRemovalDate: Calendar.current.date(byAdding: .day, value: 14, to: Date(timeIntervalSince1970: 2000))), + ] + + let mockDataBroker = DBPUIDataBroker(name: "some broker", url: "broker.com", parentURL: nil, optOutUrl: "some url") + + // When + let timelineEvents = DBPUITimelineEvent.from(historyEvents: historyEvents, removedDate: Date(timeIntervalSince1970: 5000)) + let profileMatch = DBPUIDataBrokerProfileMatch(dataBroker: mockDataBroker, + name: "some profile", + addresses: [], + alternativeNames: [], + relatives: [], + timelineEvents: timelineEvents, + hasMatchingRecordOnParentBroker: true) + + // Then + XCTAssertEqual(profileMatch.timelineEvents, expectedTimelineEvents) + } + + func testOptOutMatchInitializer() { + // Given 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) + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .scanStarted, date: Date(timeIntervalSince1970: 0)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .noMatchFound, date: Date(timeIntervalSince1970: 50)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 0, type: .matchesFound(count: 2), date: Date(timeIntervalSince1970: 100)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 2, type: .reAppearence, date: Date(timeIntervalSince1970: 2500)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 2, type: .optOutStarted, date: Date(timeIntervalSince1970: 2000)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 2, type: .optOutRequested, date: Date(timeIntervalSince1970: 2000)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 1, type: .optOutStarted, date: Date(timeIntervalSince1970: 1000)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 1, type: .optOutRequested, date: Date(timeIntervalSince1970: 1000)), + HistoryEvent(extractedProfileId: 0, brokerId: 0, profileQueryId: 1, type: .reAppearence, date: Date(timeIntervalSince1970: 1500)), ] - 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) + let expectedTimelineEvents = [ + DBPUITimelineEvent(foundDate: Date(timeIntervalSince1970: 100)), + DBPUITimelineEvent(optOutSubmittedDate: Date(timeIntervalSince1970: 1000)), + DBPUITimelineEvent(reappearedDate: Date(timeIntervalSince1970: 1500)), + DBPUITimelineEvent(optOutSubmittedDate: Date(timeIntervalSince1970: 2000)), + DBPUITimelineEvent(reappearedDate: Date(timeIntervalSince1970: 2500)), + DBPUITimelineEvent(removedDate: Date(timeIntervalSince1970: 5000)), + DBPUITimelineEvent(estimatedRemovalDate: Calendar.current.date(byAdding: .day, value: 14, to: Date(timeIntervalSince1970: 2000))), + ] + + let mockDataBroker = DBPUIDataBroker(name: "some broker", url: "broker.com", parentURL: nil, optOutUrl: "some url") // When - let profileMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOut, - dataBrokerName: "doesn't matter for the test", - dataBrokerURL: "see above", - dataBrokerParentURL: "whatever", - parentBrokerOptOutJobData: nil, - optOutUrl: "broker.com") + let timelineEvents = DBPUITimelineEvent.from(historyEvents: historyEvents, removedDate: Date(timeIntervalSince1970: 5000)) + let profileMatch = DBPUIDataBrokerProfileMatch(dataBroker: mockDataBroker, + name: "some profile", + addresses: [], + alternativeNames: [], + relatives: [], + timelineEvents: timelineEvents, + hasMatchingRecordOnParentBroker: true) + + let optOutMatch = DBPUIOptOutMatch(profileMatch: profileMatch, matches: 1) // Then - XCTAssertEqual(profileMatch.foundDate, createdDate.timeIntervalSince1970) - XCTAssertEqual(profileMatch.optOutSubmittedDate, submittedDate.timeIntervalSince1970) + XCTAssertEqual(optOutMatch?.timelineEvents, expectedTimelineEvents) + XCTAssertEqual(optOutMatch?.date, 5000) } - func testProfileMatchInit_whenCreatedDateIsDefault_thenResultingProfileMatchDatesAreBothBasedOnEventDates() { + func testProfileMatch_whenInit_thenResultingProfileMatchDatesAreBothBasedOnEventDates() { // Given let extractedProfile = ExtractedProfile.mockWithRemovedDate @@ -86,7 +157,7 @@ final class DBPUICommunicationModelTests: XCTestCase { XCTAssertEqual(profileMatch.optOutSubmittedDate, submittedEventDate.timeIntervalSince1970) } - func testProfileMatchInit_whenCreatedDateIsDefaultAndThereAreMultipleEventsOfTheSameType_thenResultingProfileMatchDatesAreBothBasedOnFirstEventDates() { + func testProfileMatch_whenInit_thenResultingProfileMatchDatesAreBothBasedOnFirstEventDates() { // Given let extractedProfile = ExtractedProfile.mockWithRemovedDate @@ -298,3 +369,13 @@ final class DBPUICommunicationModelTests: XCTestCase { XCTAssertEqual(childProfile?.dataBroker.optOutUrl, "parent.com/optout") } } + +extension DBPUIDataBrokerProfileMatch { + var foundDate: Double? { + timelineEvents.first(where: { $0.type == .recordFound })?.date + } + + var optOutSubmittedDate: Double? { + timelineEvents.first(where: { $0.type == .optOutSubmitted })?.date + } +}