Skip to content
This repository has been archived by the owner on Feb 24, 2025. It is now read-only.

Commit

Permalink
Add Privacy Stats module for collecting stats about blocked trackers (#…
Browse files Browse the repository at this point in the history
…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.
ayoy authored Nov 29, 2024
1 parent 09fd124 commit dfd266a
Showing 17 changed files with 1,593 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -539,6 +539,20 @@
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PrivacyStats"
BuildableName = "PrivacyStats"
BlueprintName = "PrivacyStats"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
@@ -818,6 +832,16 @@
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PrivacyStatsTests"
BuildableName = "PrivacyStatsTests"
BlueprintName = "PrivacyStatsTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -14,8 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/duckduckgo/content-scope-scripts",
"state" : {
"revision" : "dfef00ef77f5181d1d8a4f7cc88f7b7c0514dd34",
"version" : "6.39.0"
"revision" : "c4bb146afdf0c7a93fb9a7d95b1cb255708a470d",
"version" : "6.41.0"
}
},
{
24 changes: 23 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -46,14 +46,15 @@ let package = Package(
.library(name: "Onboarding", targets: ["Onboarding"]),
.library(name: "BrokenSitePrompt", targets: ["BrokenSitePrompt"]),
.library(name: "PageRefreshMonitor", targets: ["PageRefreshMonitor"]),
.library(name: "PrivacyStats", targets: ["PrivacyStats"]),
],
dependencies: [
.package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "15.1.0"),
.package(url: "https://github.com/duckduckgo/GRDB.swift.git", exact: "2.4.2"),
.package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "3.0.0"),
.package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.3.0"),
.package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "3.0.0"),
.package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "6.39.0"),
.package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "6.41.0"),
.package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "7.2.1"),
.package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"),
.package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"),
@@ -445,6 +446,20 @@ let package = Package(
.define("DEBUG", .when(configuration: .debug))
]
),
.target(
name: "PrivacyStats",
dependencies: [
"Common",
"Persistence",
"TrackerRadarKit"
],
resources: [
.process("PrivacyStats.xcdatamodeld")
],
swiftSettings: [
.define("DEBUG", .when(configuration: .debug))
]
),

// MARK: - Test Targets
.testTarget(
@@ -679,6 +694,13 @@ let package = Package(
"PageRefreshMonitor"
]
),
.testTarget(
name: "PrivacyStatsTests",
dependencies: [
"PrivacyStats",
"TestUtils",
]
),
],
cxxLanguageStandard: .cxx11
)
6 changes: 5 additions & 1 deletion Sources/Common/Extensions/DateExtension.swift
Original file line number Diff line number Diff line change
@@ -64,7 +64,11 @@ public extension Date {
}

var startOfDay: Date {
return Calendar.current.startOfDay(for: self)
return Calendar.current.startOfDay(for: self)
}

func daysAgo(_ days: Int) -> Date {
Calendar.current.date(byAdding: .day, value: -days, to: self)!
}

static var startOfMinuteNow: Date {
24 changes: 24 additions & 0 deletions Sources/PrivacyStats/Logger+PrivacyStats.swift
Original file line number Diff line number Diff line change
@@ -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: "") }()
}
249 changes: 249 additions & 0 deletions Sources/PrivacyStats/PrivacyStats.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never> { 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<Void, Never>

private let db: CoreDataDatabase
private let context: NSManagedObjectContext
private let currentPack: CurrentPack
private let statsUpdateSubject = PassthroughSubject<Void, Never>()
private var cancellables: Set<AnyCancellable> = []

private let errorEvents: EventMapping<PrivacyStatsError>?

public init(databaseProvider: PrivacyStatsDatabaseProviding, errorEvents: EventMapping<PrivacyStatsError>? = 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<PrivacyStatsError>?) -> 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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>PrivacyStats.xcdatamodel</string>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="24B91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="DailyBlockedTrackersEntity" representedClassName="DailyBlockedTrackersEntity" syncable="YES">
<attribute name="companyName" attributeType="String"/>
<attribute name="count" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="timestamp" attributeType="Date" usesScalarValueType="NO"/>
<fetchIndex name="byTimestampAndCompanyName">
<fetchIndexElement property="timestamp" type="Binary" order="ascending"/>
<fetchIndexElement property="companyName" type="Binary" order="ascending"/>
</fetchIndex>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="timestamp"/>
<constraint value="companyName"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>
116 changes: 116 additions & 0 deletions Sources/PrivacyStats/internal/CurrentPack.swift
Original file line number Diff line number Diff line change
@@ -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<PrivacyStatsPack, Never> = commitChangesSubject.eraseToAnyPublisher()

nonisolated private let commitChangesSubject = PassthroughSubject<PrivacyStatsPack, Never>()
private var commitTask: Task<Void, Never>?
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: [:])
}
}
55 changes: 55 additions & 0 deletions Sources/PrivacyStats/internal/DailyBlockedTrackersEntity.swift
Original file line number Diff line number Diff line change
@@ -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<DailyBlockedTrackersEntity> {
NSFetchRequest<DailyBlockedTrackersEntity>(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
}
}
51 changes: 51 additions & 0 deletions Sources/PrivacyStats/internal/Date+PrivacyStats.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
32 changes: 32 additions & 0 deletions Sources/PrivacyStats/internal/PrivacyStatsPack.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
123 changes: 123 additions & 0 deletions Sources/PrivacyStats/internal/PrivacyStatsUtils.swift
Original file line number Diff line number Diff line change
@@ -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<String>, 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<NSFetchRequestResult>(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<NSFetchRequestResult>(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()
}
}
121 changes: 121 additions & 0 deletions Tests/PrivacyStatsTests/CurrentPackTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
317 changes: 317 additions & 0 deletions Tests/PrivacyStatsTests/PrivacyStatsTests.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
360 changes: 360 additions & 0 deletions Tests/PrivacyStatsTests/PrivacyStatsUtilsTests.swift
Original file line number Diff line number Diff line change
@@ -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<String> = ["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<String> = ["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")
}
}
}
}
65 changes: 65 additions & 0 deletions Tests/PrivacyStatsTests/TestPrivacyStatsDatabaseProvider.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}

0 comments on commit dfd266a

Please sign in to comment.