diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift index d9dc3d80d0..16419ed9a5 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift @@ -160,8 +160,22 @@ struct DBPUIOptOutMatch: DBPUISendableMessage { /// Data representing the initial scan progress struct DBPUIScanProgress: DBPUISendableMessage { + + struct ScannedBroker: Codable, Equatable { + + enum Status: String, Codable { + case inProgress = "in-progress" + case completed + } + + let name: String + let url: String + let status: Status + } + let currentScans: Int let totalScans: Int + let scannedBrokers: [ScannedBroker] } /// Data to represent the intial scan state @@ -232,6 +246,6 @@ struct DBPUIDebugMetadata: DBPUISendableMessage { extension DBPUIInitialScanState { static var empty: DBPUIInitialScanState { .init(resultsFound: [DBPUIDataBrokerProfileMatch](), - scanProgress: DBPUIScanProgress(currentScans: 0, totalScans: 0)) + scanProgress: DBPUIScanProgress(currentScans: 0, totalScans: 0, scannedBrokers: [])) } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBroker.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBroker.swift index 44e62f952d..2363fec57e 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBroker.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBroker.swift @@ -77,6 +77,15 @@ struct MirrorSite: Codable, Sendable { } } +extension MirrorSite { + + typealias ScannedBroker = DBPUIScanProgress.ScannedBroker + + func scannedBroker(withStatus status: ScannedBroker.Status) -> ScannedBroker { + ScannedBroker(name: name, url: url, status: status) + } +} + public enum DataBrokerHierarchy: Int { case parent = 1 case child = 0 diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift index 31532d38d2..a5a2b4df07 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift @@ -73,7 +73,7 @@ struct DBPUICommunicationLayer: Subfeature { weak var delegate: DBPUICommunicationDelegate? private enum Constants { - static let version = 3 + static let version = 4 } internal init(webURLSettings: DataBrokerProtectionWebUIURLSettingsRepresentable) { diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift index 27ef5477a0..68e7c8671d 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift @@ -52,23 +52,27 @@ struct MapperToUI { } func initialScanState(_ brokerProfileQueryData: [BrokerProfileQueryData]) -> DBPUIInitialScanState { - // Total and current scans are misleading. The UI are counting this per broker and - // not by the total real cans that the app is doing. - let profileQueriesGroupedByBroker = Dictionary(grouping: brokerProfileQueryData, by: { $0.dataBroker.name }) - // We don't want to consider deprecated queries when reporting manual scans to the UI - let filteredProfileQueriesGroupedByBroker = profileQueriesGroupedByBroker.mapValues { queries in - queries.filter { !$0.profileQuery.deprecated } - } + let withoutDeprecated = brokerProfileQueryData.filter { !$0.profileQuery.deprecated } + + let groupedByBroker = Dictionary(grouping: withoutDeprecated, by: { $0.dataBroker.name }).values - let totalScans = filteredProfileQueriesGroupedByBroker.reduce(0) { accumulator, element in - return accumulator + element.value.totalScans + let totalScans = groupedByBroker.reduce(0) { accumulator, brokerQueryData in + return accumulator + brokerQueryData.totalScans } - let currentScans = filteredProfileQueriesGroupedByBroker.reduce(0) { accumulator, element in - return accumulator + element.value.currentScans + + let withSortedGroups = groupedByBroker.map { $0.sortedByLastRunDate() } + + let sorted = withSortedGroups.sortedByLastRunDate() + + let partiallyScannedBrokers = sorted.flatMap { brokerQueryGroup in + brokerQueryGroup.scannedBrokers } - let scanProgress = DBPUIScanProgress(currentScans: currentScans, totalScans: totalScans) + let scanProgress = DBPUIScanProgress(currentScans: partiallyScannedBrokers.count, + totalScans: totalScans, + scannedBrokers: partiallyScannedBrokers) + let matches = mapMatchesToUI(brokerProfileQueryData) return .init(resultsFound: matches, scanProgress: scanProgress) @@ -334,23 +338,80 @@ fileprivate extension BrokerProfileQueryData { } } +/// Extension on `Optional` which provides comparison abilities when the wrapped type is `Date` +private extension Optional where Wrapped == Date { + + static func < (lhs: Date?, rhs: Date?) -> Bool { + switch (lhs, rhs) { + case let (lhsDate?, rhsDate?): + return lhsDate < rhsDate + case (nil, _?): + return false + case (_?, nil): + return true + case (nil, nil): + return false + } + } + + static func == (lhs: Date?, rhs: Date?) -> Bool { + switch (lhs, rhs) { + case let (lhs?, rhs?): + return lhs == rhs + case (nil, nil): + return true + default: + return false + } + } +} + +private extension Array where Element == [BrokerProfileQueryData] { + + /// Sorts the 2-dimensional array in ascending order based on the `lastRunDate` value of the first element of each internal array + /// + /// - Returns: An array of `[BrokerProfileQueryData]` values sorted by the first `lastRunDate` of each element + func sortedByLastRunDate() -> Self { + self.sorted { lhs, rhs in + lhs.first?.scanJobData.lastRunDate < rhs.first?.scanJobData.lastRunDate + } + } +} + fileprivate extension Array where Element == BrokerProfileQueryData { + typealias ScannedBroker = DBPUIScanProgress.ScannedBroker + var totalScans: Int { guard let broker = self.first?.dataBroker else { return 0 } return 1 + broker.mirrorSites.filter { $0.shouldWeIncludeMirrorSite() }.count } - var currentScans: Int { - guard let broker = self.first?.dataBroker else { return 0 } + /// Returns an array of brokers which have been either fully or partially scanned + /// + /// A broker is considered fully scanned is all scan jobs for that broker have completed. + /// A broker is considered partially scanned if at least one scan job for that broker has completed + /// Mirror brokers will be included in the returned array when `MirrorSite.shouldWeIncludeMirrorSite` returns true + var scannedBrokers: [ScannedBroker] { + guard let broker = self.first?.dataBroker else { return [] } + + var completedScans = 0 + self.forEach { + completedScans += $0.scanJobData.lastRunDate == nil ? 0 : 1 + } - let didAllQueriesFinished = allSatisfy { $0.scanJobData.lastRunDate != nil } + guard completedScans != 0 else { return [] } - if !didAllQueriesFinished { - return 0 - } else { - return 1 + broker.mirrorSites.filter { $0.shouldWeIncludeMirrorSite() }.count + var status: ScannedBroker.Status = .inProgress + if completedScans == self.count { + status = .completed + } + + let mirrorBrokers = broker.mirrorSites.compactMap { + $0.shouldWeIncludeMirrorSite() ? $0.scannedBroker(withStatus: status) : nil } + + return [ScannedBroker(name: broker.name, url: broker.url, status: status)] + mirrorBrokers } var lastOperation: BrokerJobData? { @@ -391,6 +452,15 @@ fileprivate extension Array where Element == BrokerProfileQueryData { } }).first } + + /// Sorts the array in ascending order based on `lastRunDate` + /// + /// - Returns: An array of `BrokerProfileQueryData` sorted by `lastRunDate` + func sortedByLastRunDate() -> Self { + self.sorted { lhs, rhs in + lhs.scanJobData.lastRunDate < rhs.scanJobData.lastRunDate + } + } } fileprivate extension BrokerJobData { diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToUITests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToUITests.swift index b7fc620d08..02a1efb852 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToUITests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToUITests.swift @@ -45,7 +45,7 @@ final class MapperToUITests: XCTestCase { XCTAssertEqual(result.scanProgress.totalScans, 2) } - func testWhenAScanRanOnAllProfileQueriesOnTheSameBroker_thenCurrentScansReflectsThatScansWereDoneOnThatBroker() { + func testWhenAScanRanOnAllProfileQueriesOnTheSameBroker_thenScannedBrokersAndCurrentScansReflectsThat() { let brokerProfileQueryData: [BrokerProfileQueryData] = [ .mock(dataBrokerName: "Broker #1", lastRunDate: Date()), .mock(dataBrokerName: "Broker #1", lastRunDate: Date()), @@ -55,6 +55,23 @@ final class MapperToUITests: XCTestCase { let result = sut.initialScanState(brokerProfileQueryData) XCTAssertEqual(result.scanProgress.currentScans, 1) + XCTAssertEqual(result.scanProgress.scannedBrokers.count, result.scanProgress.currentScans) + XCTAssertEqual(result.scanProgress.scannedBrokers.first!.name, "Broker #1") + XCTAssertTrue(result.resultsFound.isEmpty) + } + + func testWhenAScanRanOnOneProfileQueryOnTheSameBroker_thenScannedBrokersAndCurrentScansReflectsThat() { + let brokerProfileQueryData: [BrokerProfileQueryData] = [ + .mock(dataBrokerName: "Broker #1", lastRunDate: Date()), + .mock(dataBrokerName: "Broker #1"), + .mock(dataBrokerName: "Broker #2") + ] + + let result = sut.initialScanState(brokerProfileQueryData) + + XCTAssertEqual(result.scanProgress.currentScans, 1) + XCTAssertEqual(result.scanProgress.scannedBrokers.count, result.scanProgress.currentScans) + XCTAssertEqual(result.scanProgress.scannedBrokers.first!.name, "Broker #1") XCTAssertTrue(result.resultsFound.isEmpty) } @@ -78,7 +95,7 @@ final class MapperToUITests: XCTestCase { XCTAssertEqual(result.resultsFound.count, 2) } - func testWhenAllScansRan_thenCurrentScansEqualsTotalScans() { + func testWhenAllScansRan_thenScannedBrokersAndCurrentScansEqualsTotalScans() { let brokerProfileQueryData: [BrokerProfileQueryData] = [ .mock(dataBrokerName: "Broker #1", lastRunDate: Date()), .mock(dataBrokerName: "Broker #1", lastRunDate: Date()), @@ -88,6 +105,9 @@ final class MapperToUITests: XCTestCase { let result = sut.initialScanState(brokerProfileQueryData) XCTAssertEqual(result.scanProgress.totalScans, result.scanProgress.currentScans) + XCTAssertEqual(result.scanProgress.currentScans, 2) + XCTAssertEqual(result.scanProgress.scannedBrokers.count, result.scanProgress.currentScans) + XCTAssertEqual(result.scanProgress.scannedBrokers.map{ $0.name }.sorted(), ["Broker #1", "Broker #2"]) } func testWhenScansHaveDeprecatedProfileQueries_thenThoseAreNotTakenIntoAccount() { @@ -102,6 +122,8 @@ final class MapperToUITests: XCTestCase { XCTAssertEqual(result.scanProgress.totalScans, 2) XCTAssertEqual(result.scanProgress.currentScans, 2) + XCTAssertEqual(result.scanProgress.scannedBrokers.count, result.scanProgress.currentScans) + XCTAssertEqual(result.scanProgress.scannedBrokers.map{ $0.name }.sorted(), ["Broker #1", "Broker #2"]) XCTAssertEqual(result.resultsFound.count, 1) } @@ -118,6 +140,8 @@ final class MapperToUITests: XCTestCase { XCTAssertEqual(result.scanProgress.totalScans, 2) XCTAssertEqual(result.scanProgress.currentScans, 2) + XCTAssertEqual(result.scanProgress.scannedBrokers.count, result.scanProgress.currentScans) + XCTAssertEqual(result.scanProgress.scannedBrokers.map{ $0.name }.sorted(), ["Broker #1", "Broker #2"]) XCTAssertEqual(result.resultsFound.count, 1) } @@ -212,7 +236,7 @@ final class MapperToUITests: XCTestCase { XCTAssertEqual(result.scanProgress.totalScans, 2) } - func testWhenMirrorSiteIsNotInRemovedPeriod_thenItShouldBeAddedToCurrentScans() { + func testWhenMirrorSiteIsNotInRemovedPeriod_thenItShouldBeAddedToScannedBrokersAndCurrentScans() { let brokerWithMirrorSiteNotRemovedAndWithScan = BrokerProfileQueryData.mock( dataBrokerName: "Broker #1", lastRunDate: Date(), @@ -227,9 +251,11 @@ final class MapperToUITests: XCTestCase { let result = sut.initialScanState(brokerProfileQueryData) XCTAssertEqual(result.scanProgress.currentScans, 2) + XCTAssertEqual(result.scanProgress.scannedBrokers.count, result.scanProgress.currentScans) + XCTAssertEqual(result.scanProgress.scannedBrokers.map{ $0.name }.sorted(), ["Broker #1", "mirror"]) } - func testWhenMirrorSiteIsInRemovedPeriod_thenItShouldNotBeAddedToCurrentScans() { + func testWhenMirrorSiteIsInRemovedPeriod_thenItShouldNotBeAddedToScannedBrokersCurrentScans() { let brokerWithMirrorSiteRemovedAndWithScan = BrokerProfileQueryData.mock( dataBrokerName: "Broker #2", lastRunDate: Date(), @@ -244,6 +270,8 @@ final class MapperToUITests: XCTestCase { let result = sut.initialScanState(brokerProfileQueryData) XCTAssertEqual(result.scanProgress.currentScans, 1) + XCTAssertEqual(result.scanProgress.scannedBrokers.count, result.scanProgress.currentScans) + XCTAssertEqual(result.scanProgress.scannedBrokers.map{ $0.name }.sorted(), ["Broker #2"]) } func testWhenMirrorSiteIsNotInRemovedPeriod_thenMatchIsAdded() { @@ -330,6 +358,46 @@ final class MapperToUITests: XCTestCase { XCTAssertEqual(result.scanSchedule.nextScan.dataBrokers.count, 3) XCTAssertTrue(areDatesEqualsOnDayMonthAndYear(date1: Date().tomorrow, date2: Date(timeIntervalSince1970: result.scanSchedule.nextScan.date))) } + + func testBrokersWithMixedScanProgress_areOrderedByLastRunDate_andHaveCorrectStatus() { + + // Given + let brokerProfileQueryData: [BrokerProfileQueryData] = [ + .mock(dataBrokerName: "Broker #1", lastRunDate: Date()), + .mock(dataBrokerName: "Broker #1", lastRunDate: Date()), + .mock(dataBrokerName: "Broker #1", lastRunDate: .minusTwoHours), + .mock(dataBrokerName: "Broker #2"), + .mock(dataBrokerName: "Broker #2", lastRunDate: .minusOneHour), + .mock(dataBrokerName: "Broker #2", lastRunDate: .minusThreeHours), + .mock(dataBrokerName: "Broker #3", lastRunDate: .minusTwoHours), + .mock(dataBrokerName: "Broker #3"), + .mock(dataBrokerName: "Broker #3", lastRunDate: Date()), + .mock(dataBrokerName: "Broker #4"), + .mock(dataBrokerName: "Broker #5"), + .mock(dataBrokerName: "Broker #7", lastRunDate: .minusThreeHours), + .mock(dataBrokerName: "Broker #6", lastRunDate: .minusThreeHours) + ] + + let expected: [DBPUIScanProgress.ScannedBroker] = [ + .mock("Broker #2", status: .inProgress), + .mock("Broker #7", status: .completed), + .mock("Broker #6", status: .completed), + .mock("Broker #1", status: .completed), + .mock("Broker #3", status: .inProgress) + ] + + let result = sut.initialScanState(brokerProfileQueryData) + + XCTAssertEqual(result.scanProgress.currentScans, 5) + XCTAssertEqual(result.scanProgress.scannedBrokers, expected) + } + +} + +extension DBPUIScanProgress.ScannedBroker { + static func mock(_ name: String, status: Self.Status) -> DBPUIScanProgress.ScannedBroker { + .init(name: name, url: "test.com", status: status) + } } extension Date { @@ -345,4 +413,21 @@ extension Date { return calendar.date(byAdding: .day, value: 1, to: self) } + + static var minusOneHour: Date? { + nowMinusHour(1) + } + + static var minusTwoHours: Date? { + nowMinusHour(2) + } + + static var minusThreeHours: Date? { + nowMinusHour(3) + } + + private static func nowMinusHour(_ hour: Int) -> Date? { + let calendar = Calendar.current + return calendar.date(byAdding: .hour, value: -hour, to: Date()) + } }