From dfd266ab6550902dc2511714f85e2cc03009c057 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Fri, 29 Nov 2024 15:38:05 +0100 Subject: [PATCH] Add Privacy Stats module for collecting stats about blocked trackers (#1097) Task/Issue URL: https://app.asana.com/0/72649045549333/1208246350498754/f Tech Design URL: https://app.asana.com/0/481882893211075/1208848285586302 Description: This change adds PrivacyStats module that will be used in macOS HTML New Tab Page. Privacy Stats uses Core Data for storage where it keeps blocked trackers counts per tracker company name per day, for past 7 days. PrivacyStats class is the main (and only) public interface. It provides simple API to fetch current stats, clear them (for Fire button use) and record blocked trackers, as well as a publisher that notifies about changes to stats. --- .../BrowserServicesKit-Package.xcscheme | 24 ++ Package.resolved | 4 +- Package.swift | 24 +- Sources/Common/Extensions/DateExtension.swift | 6 +- .../PrivacyStats/Logger+PrivacyStats.swift | 24 ++ Sources/PrivacyStats/PrivacyStats.swift | 249 ++++++++++++ .../.xccurrentversion | 8 + .../PrivacyStats.xcdatamodel/contents | 18 + .../PrivacyStats/internal/CurrentPack.swift | 116 ++++++ .../internal/DailyBlockedTrackersEntity.swift | 55 +++ .../internal/Date+PrivacyStats.swift | 51 +++ .../internal/PrivacyStatsPack.swift | 32 ++ .../internal/PrivacyStatsUtils.swift | 123 ++++++ .../PrivacyStatsTests/CurrentPackTests.swift | 121 ++++++ .../PrivacyStatsTests/PrivacyStatsTests.swift | 317 +++++++++++++++ .../PrivacyStatsUtilsTests.swift | 360 ++++++++++++++++++ .../TestPrivacyStatsDatabaseProvider.swift | 65 ++++ 17 files changed, 1593 insertions(+), 4 deletions(-) create mode 100644 Sources/PrivacyStats/Logger+PrivacyStats.swift create mode 100644 Sources/PrivacyStats/PrivacyStats.swift create mode 100644 Sources/PrivacyStats/PrivacyStats.xcdatamodeld/.xccurrentversion create mode 100644 Sources/PrivacyStats/PrivacyStats.xcdatamodeld/PrivacyStats.xcdatamodel/contents create mode 100644 Sources/PrivacyStats/internal/CurrentPack.swift create mode 100644 Sources/PrivacyStats/internal/DailyBlockedTrackersEntity.swift create mode 100644 Sources/PrivacyStats/internal/Date+PrivacyStats.swift create mode 100644 Sources/PrivacyStats/internal/PrivacyStatsPack.swift create mode 100644 Sources/PrivacyStats/internal/PrivacyStatsUtils.swift create mode 100644 Tests/PrivacyStatsTests/CurrentPackTests.swift create mode 100644 Tests/PrivacyStatsTests/PrivacyStatsTests.swift create mode 100644 Tests/PrivacyStatsTests/PrivacyStatsUtilsTests.swift create mode 100644 Tests/PrivacyStatsTests/TestPrivacyStatsDatabaseProvider.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme index 56a2ef845..b465ab3b9 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme @@ -539,6 +539,20 @@ ReferencedContainer = "container:"> + + + + + + + + Date { + Calendar.current.date(byAdding: .day, value: -days, to: self)! } static var startOfMinuteNow: Date { diff --git a/Sources/PrivacyStats/Logger+PrivacyStats.swift b/Sources/PrivacyStats/Logger+PrivacyStats.swift new file mode 100644 index 000000000..e8649a6af --- /dev/null +++ b/Sources/PrivacyStats/Logger+PrivacyStats.swift @@ -0,0 +1,24 @@ +// +// Logger+PrivacyStats.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 Foundation +import os.log + +public extension Logger { + static var privacyStats = { Logger(subsystem: "Privacy Stats", category: "") }() +} diff --git a/Sources/PrivacyStats/PrivacyStats.swift b/Sources/PrivacyStats/PrivacyStats.swift new file mode 100644 index 000000000..c298f60fc --- /dev/null +++ b/Sources/PrivacyStats/PrivacyStats.swift @@ -0,0 +1,249 @@ +// +// PrivacyStats.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 Combine +import Common +import CoreData +import Foundation +import os.log +import Persistence +import TrackerRadarKit + +/** + * Errors that may be reported by `PrivacyStats`. + */ +public enum PrivacyStatsError: CustomNSError { + case failedToFetchPrivacyStatsSummary(Error) + case failedToStorePrivacyStats(Error) + case failedToLoadCurrentPrivacyStats(Error) + + public static let errorDomain: String = "PrivacyStatsError" + + public var errorCode: Int { + switch self { + case .failedToFetchPrivacyStatsSummary: + return 1 + case .failedToStorePrivacyStats: + return 2 + case .failedToLoadCurrentPrivacyStats: + return 3 + } + } + + public var underlyingError: Error { + switch self { + case .failedToFetchPrivacyStatsSummary(let error), + .failedToStorePrivacyStats(let error), + .failedToLoadCurrentPrivacyStats(let error): + return error + } + } +} + +/** + * This protocol describes database provider consumed by `PrivacyStats`. + */ +public protocol PrivacyStatsDatabaseProviding { + func initializeDatabase() -> CoreDataDatabase +} + +/** + * This protocol describes `PrivacyStats` interface. + */ +public protocol PrivacyStatsCollecting { + + /** + * Record a tracker for a given `companyName`. + * + * `PrivacyStats` implementation calls the `CurrentPack` actor under the hood, + * and as such it can safely be called on multiple threads concurrently. + */ + func recordBlockedTracker(_ name: String) async + + /** + * Publisher emitting values whenever updated privacy stats were persisted to disk. + */ + var statsUpdatePublisher: AnyPublisher { get } + + /** + * This function fetches privacy stats in a dictionary format + * with keys being company names and values being total number + * of tracking attempts blocked in past 7 days. + */ + func fetchPrivacyStats() async -> [String: Int64] + + /** + * This function clears all blocked tracker stats from the database. + */ + func clearPrivacyStats() async + + /** + * This function saves all pending changes to the persistent storage. + * + * It should only be used in response to app termination because otherwise + * the `PrivacyStats` object schedules persisting internally. + */ + func handleAppTermination() async +} + +public final class PrivacyStats: PrivacyStatsCollecting { + + public static let bundle = Bundle.module + + public let statsUpdatePublisher: AnyPublisher + + private let db: CoreDataDatabase + private let context: NSManagedObjectContext + private let currentPack: CurrentPack + private let statsUpdateSubject = PassthroughSubject() + private var cancellables: Set = [] + + private let errorEvents: EventMapping? + + public init(databaseProvider: PrivacyStatsDatabaseProviding, errorEvents: EventMapping? = nil) { + self.db = databaseProvider.initializeDatabase() + self.context = db.makeContext(concurrencyType: .privateQueueConcurrencyType, name: "PrivacyStats") + self.errorEvents = errorEvents + self.currentPack = .init(pack: Self.initializeCurrentPack(in: context, errorEvents: errorEvents)) + statsUpdatePublisher = statsUpdateSubject.eraseToAnyPublisher() + + currentPack.commitChangesPublisher + .sink { [weak self] pack in + Task { + await self?.commitChanges(pack) + } + } + .store(in: &cancellables) + } + + public func recordBlockedTracker(_ companyName: String) async { + await currentPack.recordBlockedTracker(companyName) + } + + public func fetchPrivacyStats() async -> [String: Int64] { + return await withCheckedContinuation { continuation in + context.perform { [weak self] in + guard let self else { + continuation.resume(returning: [:]) + return + } + do { + let stats = try PrivacyStatsUtils.load7DayStats(in: context) + continuation.resume(returning: stats) + } catch { + errorEvents?.fire(.failedToFetchPrivacyStatsSummary(error)) + continuation.resume(returning: [:]) + } + } + } + } + + public func clearPrivacyStats() async { + await withCheckedContinuation { continuation in + context.perform { [weak self] in + guard let self else { + continuation.resume() + return + } + do { + try PrivacyStatsUtils.deleteAllStats(in: context) + Logger.privacyStats.debug("Deleted outdated entries") + } catch { + Logger.privacyStats.error("Save error: \(error)") + errorEvents?.fire(.failedToFetchPrivacyStatsSummary(error)) + } + continuation.resume() + } + } + await currentPack.resetPack() + statsUpdateSubject.send() + } + + public func handleAppTermination() async { + await commitChanges(currentPack.pack) + } + + // MARK: - Private + + private func commitChanges(_ pack: PrivacyStatsPack) async { + await withCheckedContinuation { continuation in + context.perform { [weak self] in + guard let self else { + continuation.resume() + return + } + + // Check if the pack we're currently storing is from a previous day. + let isCurrentDayPack = pack.timestamp == Date.currentPrivacyStatsPackTimestamp + + do { + let statsObjects = try PrivacyStatsUtils.fetchOrInsertCurrentStats(for: Set(pack.trackers.keys), in: context) + statsObjects.forEach { stats in + if let count = pack.trackers[stats.companyName] { + stats.count = count + } + } + + guard context.hasChanges else { + continuation.resume() + return + } + + try context.save() + Logger.privacyStats.debug("Saved stats \(pack.timestamp) \(pack.trackers)") + + if isCurrentDayPack { + // Only emit update event when saving current-day pack. For previous-day pack, + // a follow-up commit event will come and we'll emit the update then. + statsUpdateSubject.send() + } else { + // When storing a pack from a previous day, we may have outdated packs, so delete them as needed. + try PrivacyStatsUtils.deleteOutdatedPacks(in: context) + } + } catch { + Logger.privacyStats.error("Save error: \(error)") + errorEvents?.fire(.failedToStorePrivacyStats(error)) + } + continuation.resume() + } + } + } + + /** + * This function is only called in the initializer. It performs a blocking call to the database + * to spare us the hassle of declaring the initializer async or spawning tasks from within the + * initializer without being able to await them, thus making testing trickier. + */ + private static func initializeCurrentPack(in context: NSManagedObjectContext, errorEvents: EventMapping?) -> PrivacyStatsPack { + var pack: PrivacyStatsPack? + context.performAndWait { + let timestamp = Date.currentPrivacyStatsPackTimestamp + do { + let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context) + Logger.privacyStats.debug("Loaded stats \(timestamp) \(currentDayStats)") + pack = PrivacyStatsPack(timestamp: timestamp, trackers: currentDayStats) + + try PrivacyStatsUtils.deleteOutdatedPacks(in: context) + } catch { + Logger.privacyStats.error("Failed to load current stats: \(error)") + errorEvents?.fire(.failedToLoadCurrentPrivacyStats(error)) + } + } + return pack ?? PrivacyStatsPack(timestamp: Date.currentPrivacyStatsPackTimestamp) + } +} diff --git a/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/.xccurrentversion b/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/.xccurrentversion new file mode 100644 index 000000000..1a19d1654 --- /dev/null +++ b/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + PrivacyStats.xcdatamodel + + diff --git a/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/PrivacyStats.xcdatamodel/contents b/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/PrivacyStats.xcdatamodel/contents new file mode 100644 index 000000000..39798857a --- /dev/null +++ b/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/PrivacyStats.xcdatamodel/contents @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/PrivacyStats/internal/CurrentPack.swift b/Sources/PrivacyStats/internal/CurrentPack.swift new file mode 100644 index 000000000..fffc6b090 --- /dev/null +++ b/Sources/PrivacyStats/internal/CurrentPack.swift @@ -0,0 +1,116 @@ +// +// CurrentPack.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 Combine +import Foundation +import os.log + +/** + * This actor provides thread-safe access to an instance of `PrivacyStatsPack`. + * + * It's used by `PrivacyStats` class to record blocked trackers that can possibly + * come from multiple open tabs (web views) at the same time. + */ +actor CurrentPack { + /** + * Current stats pack. + */ + private(set) var pack: PrivacyStatsPack + + /** + * Publisher that fires events whenever tracker stats are ready to be persisted to disk. + * + * This happens after recording new blocked tracker, when no new tracker has been recorded for 1s. + */ + nonisolated private(set) lazy var commitChangesPublisher: AnyPublisher = commitChangesSubject.eraseToAnyPublisher() + + nonisolated private let commitChangesSubject = PassthroughSubject() + private var commitTask: Task? + private var commitDebounce: UInt64 + + /// The `commitDebounce` parameter should only be modified in unit tests. + init(pack: PrivacyStatsPack, commitDebounce: UInt64 = 1_000_000_000) { + self.pack = pack + self.commitDebounce = commitDebounce + } + + deinit { + commitTask?.cancel() + } + + /** + * This function is used when clearing app data, to clear any stats cached in memory. + * + * It sets a new empty pack with the current timestamp. + */ + func resetPack() { + resetStats(andSet: Date.currentPrivacyStatsPackTimestamp) + } + + /** + * This function increments trackers count for a given company name. + * + * Updates are kept in memory and scheduled for saving to persistent storage with 1s debounce. + * This function also detects when the current pack becomes outdated (which happens + * when current timestamp's day becomes greater than pack's timestamp's day), in which + * case current pack is scheduled for persisting on disk and a new empty pack is + * created for the new timestamp. + */ + func recordBlockedTracker(_ companyName: String) { + + let currentTimestamp = Date.currentPrivacyStatsPackTimestamp + if currentTimestamp != pack.timestamp { + Logger.privacyStats.debug("New timestamp detected, storing trackers state and creating new pack") + notifyChanges(for: pack, immediately: true) + resetStats(andSet: currentTimestamp) + } + + let count = pack.trackers[companyName] ?? 0 + pack.trackers[companyName] = count + 1 + + notifyChanges(for: pack, immediately: false) + } + + private func notifyChanges(for pack: PrivacyStatsPack, immediately shouldPublishImmediately: Bool) { + commitTask?.cancel() + + if shouldPublishImmediately { + + commitChangesSubject.send(pack) + + } else { + + commitTask = Task { + do { + // Note that this doesn't always sleep for the full debounce time, but the sleep is interrupted + // as soon as the task gets cancelled. + try await Task.sleep(nanoseconds: commitDebounce) + + Logger.privacyStats.debug("Storing trackers state") + commitChangesSubject.send(pack) + } catch { + // Commit task got cancelled + } + } + } + } + + private func resetStats(andSet newTimestamp: Date) { + pack = PrivacyStatsPack(timestamp: newTimestamp, trackers: [:]) + } +} diff --git a/Sources/PrivacyStats/internal/DailyBlockedTrackersEntity.swift b/Sources/PrivacyStats/internal/DailyBlockedTrackersEntity.swift new file mode 100644 index 000000000..728a5943c --- /dev/null +++ b/Sources/PrivacyStats/internal/DailyBlockedTrackersEntity.swift @@ -0,0 +1,55 @@ +// +// DailyBlockedTrackersEntity.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import CoreData + +@objc(DailyBlockedTrackersEntity) +final class DailyBlockedTrackersEntity: NSManagedObject { + enum Const { + static let entityName = "DailyBlockedTrackersEntity" + } + + @nonobjc class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: Const.entityName) + } + + class func entity(in context: NSManagedObjectContext) -> NSEntityDescription { + NSEntityDescription.entity(forEntityName: Const.entityName, in: context)! + } + + @NSManaged var companyName: String + @NSManaged var count: Int64 + @NSManaged var timestamp: Date + + private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) { + super.init(entity: entity, insertInto: context) + } + + private convenience init(context moc: NSManagedObjectContext) { + self.init(entity: DailyBlockedTrackersEntity.entity(in: moc), insertInto: moc) + } + + static func make(timestamp: Date = Date(), companyName: String, count: Int64 = 0, context: NSManagedObjectContext) -> DailyBlockedTrackersEntity { + let object = DailyBlockedTrackersEntity(context: context) + object.timestamp = timestamp.privacyStatsPackTimestamp + object.companyName = companyName + object.count = count + return object + } +} diff --git a/Sources/PrivacyStats/internal/Date+PrivacyStats.swift b/Sources/PrivacyStats/internal/Date+PrivacyStats.swift new file mode 100644 index 000000000..f56072d75 --- /dev/null +++ b/Sources/PrivacyStats/internal/Date+PrivacyStats.swift @@ -0,0 +1,51 @@ +// +// Date+PrivacyStats.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import Foundation + +extension Date { + + /** + * Returns privacy stats pack timestamp for the current date. + * + * See `privacyStatsPackTimestamp`. + */ + static var currentPrivacyStatsPackTimestamp: Date { + Date().privacyStatsPackTimestamp + } + + /** + * Returns a valid timestamp for `DailyBlockedTrackersEntity` instance matching the sender. + * + * Blocked trackers are packed by day so the timestap of the pack must be the exact start of a day. + */ + var privacyStatsPackTimestamp: Date { + startOfDay + } + + /** + * Returns the oldest valid timestamp for `DailyBlockedTrackersEntity` instance matching the sender. + * + * Privacy Stats only keeps track of 7 days worth of tracking history, so the oldest timestamp is + * beginning of the day 6 days ago. + */ + var privacyStatsOldestPackTimestamp: Date { + privacyStatsPackTimestamp.daysAgo(6) + } +} diff --git a/Sources/PrivacyStats/internal/PrivacyStatsPack.swift b/Sources/PrivacyStats/internal/PrivacyStatsPack.swift new file mode 100644 index 000000000..3c4c8a04b --- /dev/null +++ b/Sources/PrivacyStats/internal/PrivacyStatsPack.swift @@ -0,0 +1,32 @@ +// +// PrivacyStatsPack.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 Foundation + +/** + * This struct keeps track of the summary of blocked trackers for a single unit of time (1 day). + */ +struct PrivacyStatsPack: Equatable { + let timestamp: Date + var trackers: [String: Int64] + + init(timestamp: Date, trackers: [String: Int64] = [:]) { + self.timestamp = timestamp + self.trackers = trackers + } +} diff --git a/Sources/PrivacyStats/internal/PrivacyStatsUtils.swift b/Sources/PrivacyStats/internal/PrivacyStatsUtils.swift new file mode 100644 index 000000000..33b93d869 --- /dev/null +++ b/Sources/PrivacyStats/internal/PrivacyStatsUtils.swift @@ -0,0 +1,123 @@ +// +// PrivacyStatsUtils.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import CoreData +import Foundation +import Persistence + +final class PrivacyStatsUtils { + + /** + * Returns objects corresponding to current stats for companies specified by `companyNames`. + * + * If an object doesn't exist (no trackers for a given company were reported on a given day) + * then a new object for that company is inserted into the context and returned. + * If a user opens the app for the first time on a given day, the database will not contain + * any records for that day and this function will only insert new objects. + * + * > Note: `current stats` refer to stats objects that are active on a given day, i.e. their + * timestamp's day matches current day. + */ + static func fetchOrInsertCurrentStats(for companyNames: Set, in context: NSManagedObjectContext) throws -> [DailyBlockedTrackersEntity] { + let timestamp = Date.currentPrivacyStatsPackTimestamp + + let request = DailyBlockedTrackersEntity.fetchRequest() + request.predicate = NSPredicate(format: "%K == %@ AND %K in %@", + #keyPath(DailyBlockedTrackersEntity.timestamp), timestamp as NSDate, + #keyPath(DailyBlockedTrackersEntity.companyName), companyNames) + request.returnsObjectsAsFaults = false + + var statsObjects = try context.fetch(request) + let missingCompanyNames = companyNames.subtracting(statsObjects.map(\.companyName)) + + for companyName in missingCompanyNames { + statsObjects.append(DailyBlockedTrackersEntity.make(timestamp: timestamp, companyName: companyName, context: context)) + } + return statsObjects + } + + /** + * Returns a dictionary representation of blocked trackers counts grouped by company name for the current day. + */ + static func loadCurrentDayStats(in context: NSManagedObjectContext) throws -> [String: Int64] { + let startDate = Date.currentPrivacyStatsPackTimestamp + return try loadBlockedTrackersStats(since: startDate, in: context) + } + + /** + * Returns a dictionary representation of blocked trackers counts grouped by company name for past 7 days. + */ + static func load7DayStats(in context: NSManagedObjectContext) throws -> [String: Int64] { + let startDate = Date().privacyStatsOldestPackTimestamp + return try loadBlockedTrackersStats(since: startDate, in: context) + } + + private static func loadBlockedTrackersStats(since startDate: Date, in context: NSManagedObjectContext) throws -> [String: Int64] { + let request = NSFetchRequest(entityName: DailyBlockedTrackersEntity.Const.entityName) + request.predicate = NSPredicate(format: "%K >= %@", #keyPath(DailyBlockedTrackersEntity.timestamp), startDate as NSDate) + + let companyNameKey = #keyPath(DailyBlockedTrackersEntity.companyName) + + // Expression description for the sum of count + let countExpression = NSExpression(forKeyPath: #keyPath(DailyBlockedTrackersEntity.count)) + let sumExpression = NSExpression(forFunction: "sum:", arguments: [countExpression]) + + let sumExpressionDescription = NSExpressionDescription() + sumExpressionDescription.name = "totalCount" + sumExpressionDescription.expression = sumExpression + sumExpressionDescription.expressionResultType = .integer64AttributeType + + request.propertiesToGroupBy = [companyNameKey] + request.propertiesToFetch = [companyNameKey, sumExpressionDescription] + request.resultType = .dictionaryResultType + + let results = (try context.fetch(request) as? [[String: Any]]) ?? [] + + let groupedResults = results.reduce(into: [String: Int64]()) { partialResult, result in + if let companyName = result[companyNameKey] as? String, let totalCount = result["totalCount"] as? Int64, totalCount > 0 { + partialResult[companyName] = totalCount + } + } + + return groupedResults + } + + /** + * Deletes stats older than 7 days for all companies. + */ + static func deleteOutdatedPacks(in context: NSManagedObjectContext) throws { + let oldestValidTimestamp = Date().privacyStatsOldestPackTimestamp + + let fetchRequest = NSFetchRequest(entityName: DailyBlockedTrackersEntity.Const.entityName) + fetchRequest.predicate = NSPredicate(format: "%K < %@", #keyPath(DailyBlockedTrackersEntity.timestamp), oldestValidTimestamp as NSDate) + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + + try context.execute(deleteRequest) + context.reset() + } + + /** + * Deletes all stats entries in the database. + */ + static func deleteAllStats(in context: NSManagedObjectContext) throws { + let deleteRequest = NSBatchDeleteRequest(fetchRequest: DailyBlockedTrackersEntity.fetchRequest()) + try context.execute(deleteRequest) + context.reset() + } +} diff --git a/Tests/PrivacyStatsTests/CurrentPackTests.swift b/Tests/PrivacyStatsTests/CurrentPackTests.swift new file mode 100644 index 000000000..f22ed322d --- /dev/null +++ b/Tests/PrivacyStatsTests/CurrentPackTests.swift @@ -0,0 +1,121 @@ +// +// CurrentPackTests.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 Combine +import XCTest +@testable import PrivacyStats + +final class CurrentPackTests: XCTestCase { + var currentPack: CurrentPack! + + override func setUp() async throws { + currentPack = CurrentPack(pack: .init(timestamp: Date.currentPrivacyStatsPackTimestamp), commitDebounce: 10_000_000) + } + + func testThatRecordBlockedTrackerUpdatesThePack() async { + await currentPack.recordBlockedTracker("A") + let companyA = await currentPack.pack.trackers["A"] + XCTAssertEqual(companyA, 1) + } + + func testThatRecordBlockedTrackerTriggersCommitChangesEvent() async throws { + let packs = try await waitForCommitChangesEvents(for: 100_000_000) { + await currentPack.recordBlockedTracker("A") + } + + let companyA = await currentPack.pack.trackers["A"] + XCTAssertEqual(companyA, 1) + XCTAssertEqual(packs.first?.trackers["A"], 1) + } + + func testThatMultipleCallsToRecordBlockedTrackerOnlyTriggerOneCommitChangesEvent() async throws { + let packs = try await waitForCommitChangesEvents(for: 1000_000_000) { + await currentPack.recordBlockedTracker("A") + await currentPack.recordBlockedTracker("A") + await currentPack.recordBlockedTracker("A") + await currentPack.recordBlockedTracker("A") + await currentPack.recordBlockedTracker("A") + } + + XCTAssertEqual(packs.count, 1) + XCTAssertEqual(packs.first?.trackers["A"], 5) + } + + func testThatRecordBlockedTrackerCalledConcurrentlyForTheSameCompanyStoresAllCalls() async { + await withTaskGroup(of: Void.self) { group in + (0..<1000).forEach { _ in + group.addTask { + await self.currentPack.recordBlockedTracker("A") + } + } + } + let companyA = await currentPack.pack.trackers["A"] + XCTAssertEqual(companyA, 1000) + } + + func testWhenCurrentPackIsOldThenRecordBlockedTrackerSendsCommitEventAndCreatesNewPack() async throws { + let oldTimestamp = Date.currentPrivacyStatsPackTimestamp.daysAgo(1) + let pack = PrivacyStatsPack( + timestamp: oldTimestamp, + trackers: ["A": 100, "B": 50, "C": 400] + ) + currentPack = CurrentPack(pack: pack, commitDebounce: 10_000_000) + + let packs = try await waitForCommitChangesEvents(for: 100_000_000) { + await currentPack.recordBlockedTracker("A") + } + + XCTAssertEqual(packs.count, 2) + let oldPack = try XCTUnwrap(packs.first) + XCTAssertEqual(oldPack, pack) + let newPack = try XCTUnwrap(packs.last) + XCTAssertEqual(newPack, PrivacyStatsPack(timestamp: Date.currentPrivacyStatsPackTimestamp, trackers: ["A": 1])) + } + + func testThatResetPackClearsAllRecordedTrackersAndSetsCurrentTimestamp() async { + let oldTimestamp = Date.currentPrivacyStatsPackTimestamp.daysAgo(1) + let pack = PrivacyStatsPack( + timestamp: oldTimestamp, + trackers: ["A": 100, "B": 50, "C": 400] + ) + currentPack = CurrentPack(pack: pack, commitDebounce: 10_000_000) + + await currentPack.resetPack() + + let packAfterReset = await currentPack.pack + XCTAssertEqual(packAfterReset, PrivacyStatsPack(timestamp: Date.currentPrivacyStatsPackTimestamp, trackers: [:])) + } + + // MARK: - Helpers + + /** + * Sets up Combine subscription, then calls the provided block and then waits + * for the specific time before cancelling the subscription. + * Returns an array of values passed in the published events. + */ + func waitForCommitChangesEvents(for nanoseconds: UInt64, _ block: () async -> Void) async throws -> [PrivacyStatsPack] { + var packs: [PrivacyStatsPack] = [] + let cancellable = currentPack.commitChangesPublisher.sink { packs.append($0) } + + await block() + + try await Task.sleep(nanoseconds: nanoseconds) + cancellable.cancel() + return packs + } +} diff --git a/Tests/PrivacyStatsTests/PrivacyStatsTests.swift b/Tests/PrivacyStatsTests/PrivacyStatsTests.swift new file mode 100644 index 000000000..fa05d8178 --- /dev/null +++ b/Tests/PrivacyStatsTests/PrivacyStatsTests.swift @@ -0,0 +1,317 @@ +// +// PrivacyStatsTests.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 Combine +import Persistence +import TrackerRadarKit +import XCTest +@testable import PrivacyStats + +final class PrivacyStatsTests: XCTestCase { + var databaseProvider: TestPrivacyStatsDatabaseProvider! + var privacyStats: PrivacyStats! + + override func setUp() async throws { + databaseProvider = TestPrivacyStatsDatabaseProvider(databaseName: type(of: self).description()) + privacyStats = PrivacyStats(databaseProvider: databaseProvider) + } + + override func tearDown() async throws { + databaseProvider.tearDownDatabase() + } + + // MARK: - initializer + + func testThatOutdatedTrackerStatsAreDeletedUponInitialization() async throws { + try databaseProvider.addObjects { context in + let date = Date() + + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 1, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 2, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "A", count: 7, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "A", count: 100, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(8), companyName: "A", count: 100, context: context) + ] + } + + // recreate database provider with existing location so that the existing database is persisted in the initializer + databaseProvider = TestPrivacyStatsDatabaseProvider(databaseName: type(of: self).description(), location: databaseProvider.location) + privacyStats = PrivacyStats(databaseProvider: databaseProvider) + + let stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 10]) + + let context = databaseProvider.database.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + do { + let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest()) + XCTAssertEqual(Set(allObjects.map(\.count)), [1, 2, 7]) + } catch { + XCTFail("Context fetch should not fail") + } + } + } + + // MARK: - fetchPrivacyStats + + func testThatPrivacyStatsAreFetched() async throws { + let stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, [:]) + } + + func testThatFetchPrivacyStatsReturnsAllCompanies() async throws { + try databaseProvider.addObjects { context in + [ + DailyBlockedTrackersEntity.make(companyName: "A", count: 10, context: context), + DailyBlockedTrackersEntity.make(companyName: "B", count: 5, context: context), + DailyBlockedTrackersEntity.make(companyName: "C", count: 13, context: context), + DailyBlockedTrackersEntity.make(companyName: "D", count: 42, context: context) + ] + } + + let stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 10, "B": 5, "C": 13, "D": 42]) + } + + func testThatFetchPrivacyStatsReturnsSumOfCompanyEntriesForPast7Days() async throws { + try databaseProvider.addObjects { context in + let date = Date() + + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 1, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 2, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(2), companyName: "A", count: 3, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(3), companyName: "A", count: 4, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(4), companyName: "A", count: 5, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(5), companyName: "A", count: 6, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "A", count: 7, context: context) + ] + } + + let stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 28]) + } + + func testThatFetchPrivacyStatsDiscardsEntriesOlderThan7Days() async throws { + try databaseProvider.addObjects { context in + let date = Date() + + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 1, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 2, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "A", count: 10, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(10), companyName: "A", count: 10, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(20), companyName: "A", count: 10, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(500), companyName: "A", count: 10, context: context), + ] + } + + let stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 3]) + } + + // MARK: - recordBlockedTracker + + func testThatCallingRecordBlockedTrackerCausesDatabaseSaveAfterDelay() async throws { + await privacyStats.recordBlockedTracker("A") + + var stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, [:]) + + try await Task.sleep(nanoseconds: 1_500_000_000) + + stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 1]) + } + + func testThatStatsUpdatePublisherIsCalledAfterDatabaseSave() async throws { + await privacyStats.recordBlockedTracker("A") + + await waitForStatsUpdateEvent() + + var stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 1]) + + await privacyStats.recordBlockedTracker("B") + + await waitForStatsUpdateEvent() + + stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 1, "B": 1]) + } + + func testWhenMultipleTrackersAreReportedInQuickSuccessionThenOnlyOneStatsUpdateEventIsReported() async throws { + await withTaskGroup(of: Void.self) { group in + (0..<5).forEach { _ in + group.addTask { + await self.privacyStats.recordBlockedTracker("A") + } + } + (0..<10).forEach { _ in + group.addTask { + await self.privacyStats.recordBlockedTracker("B") + } + } + (0..<3).forEach { _ in + group.addTask { + await self.privacyStats.recordBlockedTracker("C") + } + } + } + + // We have limited testing possibilities here, so let's just await the first stats update event + // and verify that all trackers are reported by privacy stats. + await waitForStatsUpdateEvent() + + let stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 5, "B": 10, "C": 3]) + } + + func testThatCallingRecordBlockedTrackerWithNextDayTimestampCausesDeletingOldEntriesFromDatabase() async throws { + try databaseProvider.addObjects { context in + let date = Date() + return [ + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 2, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "A", count: 100, context: context), + ] + } + + // recreate database provider with existing location so that the existing database is persisted in the initializer + databaseProvider = TestPrivacyStatsDatabaseProvider(databaseName: type(of: self).description(), location: databaseProvider.location) + privacyStats = PrivacyStats(databaseProvider: databaseProvider) + + await privacyStats.recordBlockedTracker("A") + + // No waiting here because the first commit event will be sent immediately from the actor when pack's timestamp changes. + // We aren't testing the debounced commit in this test case. + + var stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 2]) + + let context = databaseProvider.database.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + do { + let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest()) + XCTAssertEqual(Set(allObjects.map(\.count)), [2]) + } catch { + XCTFail("Context fetch should not fail") + } + } + + await waitForStatsUpdateEvent() + stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 3]) + } + + // MARK: - clearPrivacyStats + + func testThatClearPrivacyStatsTriggersUpdatesPublisher() async throws { + try await waitForStatsUpdateEvents(for: 1, count: 1) { + await privacyStats.clearPrivacyStats() + } + } + + func testWhenClearPrivacyStatsIsCalledThenFetchPrivacyStatsIsEmpty() async throws { + try databaseProvider.addObjects { context in + let date = Date() + + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 1, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 2, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "A", count: 10, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(10), companyName: "A", count: 10, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(20), companyName: "A", count: 10, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(500), companyName: "A", count: 10, context: context), + ] + } + + var stats = await privacyStats.fetchPrivacyStats() + XCTAssertFalse(stats.isEmpty) + + await privacyStats.clearPrivacyStats() + + stats = await privacyStats.fetchPrivacyStats() + XCTAssertTrue(stats.isEmpty) + + let context = databaseProvider.database.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + do { + let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest()) + XCTAssertTrue(allObjects.isEmpty) + } catch { + XCTFail("fetch failed: \(error)") + } + } + } + + // MARK: - handleAppTermination + + func testThatHandleAppTerminationSavesCurrentPack() async throws { + let context = databaseProvider.database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest()) + XCTAssertTrue(allObjects.isEmpty) + } catch { + XCTFail("fetch failed: \(error)") + } + } + await privacyStats.recordBlockedTracker("A") + await privacyStats.handleAppTermination() + + context.performAndWait { + do { + let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest()) + XCTAssertEqual(allObjects.count, 1) + } catch { + XCTFail("fetch failed: \(error)") + } + } + + await waitForStatsUpdateEvent() + let stats = await privacyStats.fetchPrivacyStats() + XCTAssertEqual(stats, ["A": 1]) + } + + // MARK: - Helpers + + func waitForStatsUpdateEvent(file: StaticString = #file, line: UInt = #line) async { + let expectation = self.expectation(description: "statsUpdate") + let cancellable = privacyStats.statsUpdatePublisher.sink { expectation.fulfill() } + await fulfillment(of: [expectation], timeout: 2) + cancellable.cancel() + } + + /** + * Sets up an expectation with the fulfillment count specified by `count` parameter, + * then sets up Combine subscription, then calls the provided block and waits + * for time specified by `duration` before cancelling the subscription. + */ + func waitForStatsUpdateEvents(for duration: TimeInterval, count: Int, _ block: () async -> Void) async throws { + let expectation = self.expectation(description: "statsUpdate") + expectation.expectedFulfillmentCount = count + let cancellable = privacyStats.statsUpdatePublisher.sink { expectation.fulfill() } + + await block() + + await fulfillment(of: [expectation], timeout: duration) + cancellable.cancel() + } +} diff --git a/Tests/PrivacyStatsTests/PrivacyStatsUtilsTests.swift b/Tests/PrivacyStatsTests/PrivacyStatsUtilsTests.swift new file mode 100644 index 000000000..ec0e7e606 --- /dev/null +++ b/Tests/PrivacyStatsTests/PrivacyStatsUtilsTests.swift @@ -0,0 +1,360 @@ +// +// PrivacyStatsUtilsTests.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 Persistence +import XCTest +@testable import PrivacyStats + +final class PrivacyStatsUtilsTests: XCTestCase { + var databaseProvider: TestPrivacyStatsDatabaseProvider! + var database: CoreDataDatabase! + + override func setUp() async throws { + databaseProvider = TestPrivacyStatsDatabaseProvider(databaseName: type(of: self).description()) + databaseProvider.initializeDatabase() + database = databaseProvider.database + } + + override func tearDown() async throws { + databaseProvider.tearDownDatabase() + } + + // MARK: - fetchOrInsertCurrentStats + + func testWhenThereAreNoObjectsForCompaniesThenFetchOrInsertCurrentStatsInsertsNewObjects() { + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + let currentPackTimestamp = Date.currentPrivacyStatsPackTimestamp + let companyNames: Set = ["A", "B", "C", "D"] + + var returnedEntities: [DailyBlockedTrackersEntity] = [] + do { + returnedEntities = try PrivacyStatsUtils.fetchOrInsertCurrentStats(for: companyNames, in: context) + } catch { + XCTFail("Should not throw") + } + + let insertedEntities = context.insertedObjects.compactMap { $0 as? DailyBlockedTrackersEntity } + + XCTAssertEqual(returnedEntities.count, 4) + XCTAssertEqual(insertedEntities.count, 4) + XCTAssertEqual(Set(insertedEntities.map(\.companyName)), companyNames) + XCTAssertEqual(Set(insertedEntities.map(\.companyName)), Set(returnedEntities.map(\.companyName))) + + // All inserted entries have the same timestamp + XCTAssertEqual(Set(insertedEntities.map(\.timestamp)), [currentPackTimestamp]) + + // All inserted entries have the count of 0 + XCTAssertEqual(Set(insertedEntities.map(\.count)), [0]) + } + } + + func testWhenThereAreExistingObjectsForCompaniesThenFetchOrInsertCurrentStatsReturnsThem() throws { + let date = Date() + + try databaseProvider.addObjects { context in + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 123, context: context), + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "B", count: 4567, context: context) + ] + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + let companyNames: Set = ["A", "B", "C", "D"] + + var returnedEntities: [DailyBlockedTrackersEntity] = [] + do { + returnedEntities = try PrivacyStatsUtils.fetchOrInsertCurrentStats(for: companyNames, in: context) + } catch { + XCTFail("Should not throw") + } + + let insertedEntities = context.insertedObjects.compactMap { $0 as? DailyBlockedTrackersEntity } + + XCTAssertEqual(returnedEntities.count, 4) + XCTAssertEqual(insertedEntities.count, 2) + XCTAssertEqual(Set(returnedEntities.map(\.companyName)), companyNames) + XCTAssertEqual(Set(insertedEntities.map(\.companyName)), ["C", "D"]) + + do { + let companyA = try XCTUnwrap(returnedEntities.first { $0.companyName == "A" }) + let companyB = try XCTUnwrap(returnedEntities.first { $0.companyName == "B" }) + + XCTAssertEqual(companyA.count, 123) + XCTAssertEqual(companyB.count, 4567) + } catch { + XCTFail("Should find companies A and B") + } + } + } + + // MARK: - loadCurrentDayStats + + func testWhenThereAreNoObjectsInDatabaseThenLoadCurrentDayStatsIsEmpty() throws { + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context) + XCTAssertTrue(currentDayStats.isEmpty) + } catch { + XCTFail("Should not throw") + } + } + } + + func testWhenThereAreObjectsInDatabaseForPreviousDaysThenLoadCurrentDayStatsIsEmpty() throws { + let date = Date() + + try databaseProvider.addObjects { context in + return [ + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 123, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(2), companyName: "B", count: 4567, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(5), companyName: "C", count: 890, context: context) + ] + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context) + XCTAssertTrue(currentDayStats.isEmpty) + } catch { + XCTFail("Should not throw") + } + } + } + + func testThatObjectsWithZeroCountAreNotReportedByLoadCurrentDayStats() throws { + let date = Date() + + try databaseProvider.addObjects { context in + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 0, context: context), + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "B", count: 0, context: context), + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "C", count: 0, context: context) + ] + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context) + XCTAssertTrue(currentDayStats.isEmpty) + } catch { + XCTFail("Should not throw") + } + } + } + + func testThatObjectsWithNonZeroCountAreReportedByLoadCurrentDayStats() throws { + let date = Date() + + try databaseProvider.addObjects { context in + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 150, context: context), + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "B", count: 400, context: context), + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "C", count: 84, context: context), + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "D", count: 5, context: context) + ] + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context) + XCTAssertEqual(currentDayStats, ["A": 150, "B": 400, "C": 84, "D": 5]) + } catch { + XCTFail("Should not throw") + } + } + } + + // MARK: - load7DayStats + + func testWhenThereAreNoObjectsInDatabaseThenLoad7DayStatsIsEmpty() throws { + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + let stats = try PrivacyStatsUtils.load7DayStats(in: context) + XCTAssertTrue(stats.isEmpty) + } catch { + XCTFail("Should not throw") + } + } + } + + func testWhenThereAreObjectsInDatabaseFrom7DaysAgoOrMoreThenLoad7DayStatsIsEmpty() throws { + let date = Date() + + try databaseProvider.addObjects { context in + return [ + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(10), companyName: "A", count: 123, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(20), companyName: "B", count: 4567, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "C", count: 890, context: context) + ] + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + let stats = try PrivacyStatsUtils.load7DayStats(in: context) + XCTAssertTrue(stats.isEmpty) + } catch { + XCTFail("Should not throw") + } + } + } + + func testThatObjectsWithZeroCountAreNotReportedByLoad7DayStats() throws { + let date = Date() + + try databaseProvider.addObjects { context in + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 0, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(4), companyName: "B", count: 0, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "C", count: 0, context: context) + ] + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + let stats = try PrivacyStatsUtils.load7DayStats(in: context) + XCTAssertTrue(stats.isEmpty) + } catch { + XCTFail("Should not throw") + } + } + } + + func testThatObjectsWithNonZeroCountAreReportedByLoad7DayStats() throws { + let date = Date() + + try databaseProvider.addObjects { context in + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 150, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "B", count: 400, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(2), companyName: "C", count: 84, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "D", count: 5, context: context) + ] + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + let stats = try PrivacyStatsUtils.load7DayStats(in: context) + XCTAssertEqual(stats, ["A": 150, "B": 400, "C": 84, "D": 5]) + } catch { + XCTFail("Should not throw") + } + } + } + + // MARK: - deleteOutdatedPacks + + func testWhenDeleteOutdatedPacksIsCalledThenObjectsFrom7DaysAgoOrMoreAreDeleted() throws { + let date = Date() + + try databaseProvider.addObjects { context in + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "C", count: 1, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(4), companyName: "C", count: 2, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "C", count: 3, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "C", count: 4, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(8), companyName: "C", count: 5, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(100), companyName: "C", count: 6, context: context) + ] + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + try PrivacyStatsUtils.deleteOutdatedPacks(in: context) + + let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest()) + XCTAssertEqual(Set(allObjects.map(\.count)), [1, 2, 3]) + } catch { + XCTFail("Should not throw") + } + } + } + + func testWhenObjectsFrom7DaysAgoOrMoreAreNotPresentThenDeleteOutdatedPacksHasNoEffect() throws { + let date = Date() + + try databaseProvider.addObjects { context in + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "C", count: 1, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(4), companyName: "C", count: 2, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "C", count: 3, context: context) + ] + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + try PrivacyStatsUtils.deleteOutdatedPacks(in: context) + + let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest()) + XCTAssertEqual(allObjects.count, 3) + } catch { + XCTFail("Should not throw") + } + } + } + + // MARK: - deleteAllStats + + func testThatDeleteAllStatsRemovesAllDatabaseObjects() throws { + let date = Date() + + try databaseProvider.addObjects { context in + return [ + DailyBlockedTrackersEntity.make(timestamp: date, companyName: "C", count: 1, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(4), companyName: "C", count: 2, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "C", count: 3, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(60), companyName: "C", count: 3, context: context), + DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(600), companyName: "C", count: 3, context: context) + ] + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + try PrivacyStatsUtils.deleteAllStats(in: context) + + let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest()) + XCTAssertTrue(allObjects.isEmpty) + } catch { + XCTFail("Should not throw") + } + } + } +} diff --git a/Tests/PrivacyStatsTests/TestPrivacyStatsDatabaseProvider.swift b/Tests/PrivacyStatsTests/TestPrivacyStatsDatabaseProvider.swift new file mode 100644 index 000000000..2cb210f0b --- /dev/null +++ b/Tests/PrivacyStatsTests/TestPrivacyStatsDatabaseProvider.swift @@ -0,0 +1,65 @@ +// +// TestPrivacyStatsDatabaseProvider.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 Persistence +import XCTest +@testable import PrivacyStats + +final class TestPrivacyStatsDatabaseProvider: PrivacyStatsDatabaseProviding { + let databaseName: String + var database: CoreDataDatabase! + var location: URL! + + init(databaseName: String) { + self.databaseName = databaseName + } + + init(databaseName: String, location: URL) { + self.databaseName = databaseName + self.location = location + } + + @discardableResult + func initializeDatabase() -> CoreDataDatabase { + if location == nil { + location = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } + let model = CoreDataDatabase.loadModel(from: PrivacyStats.bundle, named: "PrivacyStats")! + database = CoreDataDatabase(name: databaseName, containerLocation: location, model: model) + database.loadStore() + return database + } + + func tearDownDatabase() { + try? database.tearDown(deleteStores: true) + database = nil + try? FileManager.default.removeItem(at: location) + } + + func addObjects(_ objects: (NSManagedObjectContext) -> [DailyBlockedTrackersEntity], file: StaticString = #file, line: UInt = #line) throws { + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + _ = objects(context) + do { + try context.save() + } catch { + XCTFail("save failed: \(error)", file: file, line: line) + } + } + } +}