diff --git a/Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift b/Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift new file mode 100644 index 000000000..abd01290b --- /dev/null +++ b/Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift @@ -0,0 +1,107 @@ +// +// ExperimentCohortsManager.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 + +struct ExperimentSubfeature { + let subfeatureID: SubfeatureID + let cohorts: [PrivacyConfigurationData.Cohort] +} + +typealias CohortID = String +typealias SubfeatureID = String + +struct ExperimentData: Codable, Equatable { + let cohort: String + let enrollmentDate: Date +} + +typealias Experiments = [String: ExperimentData] + +protocol ExperimentCohortsManaging { + /// Retrieves the cohort ID associated with the specified subfeature. + /// - Parameter subfeatureID: The name of the experiment subfeature for which the cohort ID is needed. + /// - Returns: The cohort ID as a `String` if one exists; otherwise, returns `nil`. + func cohort(for subfeatureID: SubfeatureID) -> CohortID? + + /// Retrieves the enrollment date for the specified subfeature. + /// - Parameter subfeatureID: The name of the experiment subfeature for which the enrollment date is needed. + /// - Returns: The `Date` of enrollment if one exists; otherwise, returns `nil`. + func enrollmentDate(for subfeatureID: SubfeatureID) -> Date? + + /// Assigns a cohort to the given subfeature based on defined weights and saves it to UserDefaults. + /// - Parameter subfeature: The ExperimentSubfeature to which a cohort needs to be assigned to. + /// - Returns: The name of the assigned cohort, or `nil` if no cohort could be assigned. + func assignCohort(to subfeature: ExperimentSubfeature) -> CohortID? + + /// Removes the assigned cohort data for the specified subfeature. + /// - Parameter subfeatureID: The name of the experiment subfeature for which the cohort data should be removed. + func removeCohort(from subfeatureID: SubfeatureID) +} + +final class ExperimentCohortsManager: ExperimentCohortsManaging { + + private var store: ExperimentsDataStoring + private let randomizer: (Range) -> Double + + init(store: ExperimentsDataStoring = ExperimentsDataStore(), randomizer: @escaping (Range) -> Double) { + self.store = store + self.randomizer = randomizer + } + + func cohort(for subfeatureID: SubfeatureID) -> CohortID? { + guard let experiments = store.experiments else { return nil } + return experiments[subfeatureID]?.cohort + } + + func enrollmentDate(for subfeatureID: SubfeatureID) -> Date? { + guard let experiments = store.experiments else { return nil } + return experiments[subfeatureID]?.enrollmentDate + } + + func assignCohort(to subfeature: ExperimentSubfeature) -> CohortID? { + let cohorts = subfeature.cohorts + let totalWeight = cohorts.map(\.weight).reduce(0, +) + guard totalWeight > 0 else { return nil } + + let randomValue = randomizer(0.. Data? + func set(_ value: Any?, forKey defaultName: String) +} + +struct ExperimentsDataStore: ExperimentsDataStoring { + + private enum Constants { + static let experimentsDataKey = "ExperimentsData" + } + private let localDataStoring: LocalDataStoring + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init(localDataStoring: LocalDataStoring = UserDefaults.standard) { + self.localDataStoring = localDataStoring + encoder.dateEncodingStrategy = .secondsSince1970 + decoder.dateDecodingStrategy = .secondsSince1970 + } + + var experiments: Experiments? { + get { + guard let savedData = localDataStoring.data(forKey: Constants.experimentsDataKey) else { return nil } + return try? decoder.decode(Experiments.self, from: savedData) + } + set { + if let encodedData = try? encoder.encode(newValue) { + localDataStoring.set(encodedData, forKey: Constants.experimentsDataKey) + } + } + } +} + +extension UserDefaults: LocalDataStoring {} diff --git a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift index 813c87503..7cbd2bc71 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift @@ -36,6 +36,20 @@ public struct PrivacyConfigurationData { static public let enabled = "enabled" } + public struct Cohort { + public let name: String + public let weight: Int + + public init?(json: [String: Any]) { + guard let name = json["name"] as? String, + let weight = json["weight"] as? Int else { + return nil + } + + self.name = name + self.weight = weight + } + } public let features: [FeatureName: PrivacyFeature] public let trackerAllowlist: TrackerAllowlist public let unprotectedTemporary: [ExceptionEntry] @@ -121,6 +135,7 @@ public struct PrivacyConfigurationData { case state case minSupportedVersion case rollout + case cohorts } public struct Rollout: Hashable { @@ -157,6 +172,7 @@ public struct PrivacyConfigurationData { public let state: FeatureState public let minSupportedVersion: FeatureSupportedVersion? public let rollout: Rollout? + public let cohorts: [Cohort]? public init?(json: [String: Any]) { guard let state = json[CodingKeys.state.rawValue] as? String else { @@ -171,6 +187,13 @@ public struct PrivacyConfigurationData { } else { self.rollout = nil } + + if let cohortData = json[CodingKeys.cohorts.rawValue] as? [[String: Any]] { + let parsedCohorts = cohortData.compactMap { Cohort(json: $0) } + cohorts = parsedCohorts.isEmpty ? nil : parsedCohorts + } else { + cohorts = nil + } } } diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentCohortsManagerTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentCohortsManagerTests.swift new file mode 100644 index 000000000..518249560 --- /dev/null +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentCohortsManagerTests.swift @@ -0,0 +1,266 @@ +// +// ExperimentCohortsManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import BrowserServicesKit + +final class ExperimentCohortsManagerTests: XCTestCase { + + var mockStore: MockExperimentDataStore! + var experimentCohortsManager: ExperimentCohortsManager! + + let subfeatureName1 = "TestSubfeature1" + var experimentData1: ExperimentData! + + let subfeatureName2 = "TestSubfeature2" + var experimentData2: ExperimentData! + + let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + return encoder + }() + + override func setUp() { + super.setUp() + mockStore = MockExperimentDataStore() + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore, + randomizer: { _ in 50.0 } + ) + + let expectedDate1 = Date() + experimentData1 = ExperimentData(cohort: "TestCohort1", enrollmentDate: expectedDate1) + + let expectedDate2 = Date().addingTimeInterval(60) + experimentData2 = ExperimentData(cohort: "TestCohort2", enrollmentDate: expectedDate2) + } + + override func tearDown() { + mockStore = nil + experimentCohortsManager = nil + experimentData1 = nil + experimentData2 = nil + super.tearDown() + } + + func testCohortReturnsCohortIDIfExistsForMultipleSubfeatures() { + // GIVEN + mockStore.experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] + + // WHEN + let result1 = experimentCohortsManager.cohort(for: subfeatureName1) + let result2 = experimentCohortsManager.cohort(for: subfeatureName2) + + // THEN + XCTAssertEqual(result1, experimentData1.cohort) + XCTAssertEqual(result2, experimentData2.cohort) + } + + func testEnrollmentDateReturnsCorrectDateIfExists() { + // GIVEN + mockStore.experiments = [subfeatureName1: experimentData1] + + // WHEN + let result1 = experimentCohortsManager.enrollmentDate(for: subfeatureName1) + let result2 = experimentCohortsManager.enrollmentDate(for: subfeatureName2) + + // THEN + let timeDifference1 = abs(experimentData1.enrollmentDate.timeIntervalSince(result1 ?? Date())) + + XCTAssertLessThanOrEqual(timeDifference1, 1.0, "Expected enrollment date for subfeatureName1 to match at the second level") + XCTAssertNil(result2) + } + + func testCohortReturnsNilIfCohortDoesNotExist() { + // GIVEN + let subfeatureName = "TestSubfeature" + + // WHEN + let result = experimentCohortsManager.cohort(for: subfeatureName) + + // THEN + XCTAssertNil(result) + } + + func testEnrollmentDateReturnsNilIfDateDoesNotExist() { + // GIVEN + let subfeatureName = "TestSubfeature" + + // WHEN + let result = experimentCohortsManager.enrollmentDate(for: subfeatureName) + + // THEN + XCTAssertNil(result) + } + + func testRemoveCohortSuccessfullyRemovesData() throws { + // GIVEN + mockStore.experiments = [subfeatureName1: experimentData1] + + // WHEN + experimentCohortsManager.removeCohort(from: subfeatureName1) + + // THEN + let experiments = try XCTUnwrap(mockStore.experiments) + XCTAssertTrue(experiments.isEmpty) + } + + func testRemoveCohortDoesNothingIfSubfeatureDoesNotExist() { + // GIVEN + let expectedExperiments: Experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] + mockStore.experiments = expectedExperiments + + // WHEN + experimentCohortsManager.removeCohort(from: "someOtherSubfeature") + + // THEN + XCTAssertEqual( mockStore.experiments, expectedExperiments) + } + + func testAssignCohortReturnsNilIfNoCohorts() { + // GIVEN + let subfeature = ExperimentSubfeature(subfeatureID: subfeatureName1, cohorts: []) + + // WHEN + let result = experimentCohortsManager.assignCohort(to: subfeature) + + // THEN + XCTAssertNil(result) + } + + func testAssignCohortReturnsNilIfAllWeightsAreZero() { + // GIVEN + let jsonCohort1: [String: Any] = ["name": "TestCohort", "weight": 0] + let jsonCohort2: [String: Any] = ["name": "TestCohort", "weight": 0] + let cohorts = [ + PrivacyConfigurationData.Cohort(json: jsonCohort1)!, + PrivacyConfigurationData.Cohort(json: jsonCohort2)! + ] + let subfeature = ExperimentSubfeature(subfeatureID: subfeatureName1, cohorts: cohorts) + + // WHEN + let result = experimentCohortsManager.assignCohort(to: subfeature) + + // THEN + XCTAssertNil(result) + } + + func testAssignCohortSelectsCorrectCohortBasedOnWeight() { + // Cohort1 has weight 1, Cohort2 has weight 3 + // Total weight is 1 + 3 = 4 + let jsonCohort1: [String: Any] = ["name": "Cohort1", "weight": 1] + let jsonCohort2: [String: Any] = ["name": "Cohort2", "weight": 3] + let cohorts = [ + PrivacyConfigurationData.Cohort(json: jsonCohort1)!, + PrivacyConfigurationData.Cohort(json: jsonCohort2)! + ] + let subfeature = ExperimentSubfeature(subfeatureID: subfeatureName1, cohorts: cohorts) + let expectedTotalWeight = 4.0 + + // Use a custom randomizer to verify the range + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore, + randomizer: { range in + // Assert that the range lower bound is 0 + XCTAssertEqual(range.lowerBound, 0.0) + // Assert that the range upper bound is the total weight + XCTAssertEqual(range.upperBound, expectedTotalWeight) + return 0.0 + } + ) + + // Test case where random value is at the very start of Cohort1's range (0) + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore, + randomizer: { _ in 0.0 } + ) + let resultStartOfCohort1 = experimentCohortsManager.assignCohort(to: subfeature) + XCTAssertEqual(resultStartOfCohort1, "Cohort1") + + // Test case where random value is at the end of Cohort1's range (0.9) + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore, + randomizer: { _ in 0.9 } + ) + let resultEndOfCohort1 = experimentCohortsManager.assignCohort(to: subfeature) + XCTAssertEqual(resultEndOfCohort1, "Cohort1") + + // Test case where random value is at the start of Cohort2's range (1.00 to 4) + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore, + randomizer: { _ in 1.00 } + ) + let resultStartOfCohort2 = experimentCohortsManager.assignCohort(to: subfeature) + XCTAssertEqual(resultStartOfCohort2, "Cohort2") + + // Test case where random value falls exactly within Cohort2's range (2.5) + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore, + randomizer: { _ in 2.5 } + ) + let resultMiddleOfCohort2 = experimentCohortsManager.assignCohort(to: subfeature) + XCTAssertEqual(resultMiddleOfCohort2, "Cohort2") + + // Test case where random value is at the end of Cohort2's range (4) + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore, + randomizer: { _ in 3.9 } + ) + let resultEndOfCohort2 = experimentCohortsManager.assignCohort(to: subfeature) + XCTAssertEqual(resultEndOfCohort2, "Cohort2") + } + + func testAssignCohortWithSingleCohortAlwaysSelectsThatCohort() throws { + // GIVEN + let jsonCohort1: [String: Any] = ["name": "Cohort1", "weight": 1] + let cohorts = [ + PrivacyConfigurationData.Cohort(json: jsonCohort1)! + ] + let subfeature = ExperimentSubfeature(subfeatureID: subfeatureName1, cohorts: cohorts) + let expectedTotalWeight = 1.0 + + // Use a custom randomizer to verify the range + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore, + randomizer: { range in + // Assert that the range lower bound is 0 + XCTAssertEqual(range.lowerBound, 0.0) + // Assert that the range upper bound is the total weight + XCTAssertEqual(range.upperBound, expectedTotalWeight) + return 0.0 + } + ) + + // WHEN + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore, + randomizer: { range in Double.random(in: range)} + ) + let result = experimentCohortsManager.assignCohort(to: subfeature) + + // THEN + XCTAssertEqual(result, "Cohort1") + XCTAssertEqual(cohorts[0].name, mockStore.experiments?[subfeature.subfeatureID]?.cohort) + } + +} + +class MockExperimentDataStore: ExperimentsDataStoring { + var experiments: Experiments? +} diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentsDataStoreTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentsDataStoreTests.swift new file mode 100644 index 000000000..0466155b0 --- /dev/null +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentsDataStoreTests.swift @@ -0,0 +1,105 @@ +// +// ExperimentsDataStoreTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import BrowserServicesKit + +final class ExperimentsDataStoreTests: XCTestCase { + + let subfeatureName1 = "TestSubfeature1" + var expectedDate1: Date! + var experimentData1: ExperimentData! + + let subfeatureName2 = "TestSubfeature2" + var expectedDate2: Date! + var experimentData2: ExperimentData! + + var mockDataStore: MockLocalDataStore! + var experimentsDataStore: ExperimentsDataStore! + let testExperimentKey = "ExperimentsData" + + override func setUp() { + super.setUp() + mockDataStore = MockLocalDataStore() + experimentsDataStore = ExperimentsDataStore(localDataStoring: mockDataStore) + } + + override func tearDown() { + mockDataStore = nil + experimentsDataStore = nil + super.tearDown() + } + + func testExperimentsGetReturnsDecodedExperiments() { + // GIVEN + let experimentData1 = ExperimentData(cohort: "TestCohort1", enrollmentDate: Date()) + let experimentData2 = ExperimentData(cohort: "TestCohort2", enrollmentDate: Date()) + let experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let encodedData = try? encoder.encode(experiments) + mockDataStore.data = encodedData + + // WHEN + let result = experimentsDataStore.experiments + + // THEN + let timeDifference1 = abs(experimentData1.enrollmentDate.timeIntervalSince(result?[subfeatureName1]?.enrollmentDate ?? Date())) + let timeDifference2 = abs(experimentData2.enrollmentDate.timeIntervalSince(result?[subfeatureName2]?.enrollmentDate ?? Date())) + XCTAssertEqual(result?[subfeatureName1]?.cohort, experimentData1.cohort) + XCTAssertLessThanOrEqual(timeDifference1, 1.0) + + XCTAssertEqual(result?[subfeatureName2]?.cohort, experimentData2.cohort) + XCTAssertLessThanOrEqual(timeDifference2, 1.0) + } + + func testExperimentsSetEncodesAndStoresData() throws { + // GIVEN + let experimentData1 = ExperimentData(cohort: "TestCohort1", enrollmentDate: Date()) + let experimentData2 = ExperimentData(cohort: "TestCohort2", enrollmentDate: Date()) + let experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] + + // WHEN + experimentsDataStore.experiments = experiments + + // THEN + let storedData = try XCTUnwrap(mockDataStore.data) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let decodedExperiments = try? decoder.decode(Experiments.self, from: storedData) + let timeDifference1 = abs(experimentData1.enrollmentDate.timeIntervalSince(decodedExperiments?[subfeatureName1]?.enrollmentDate ?? Date())) + let timeDifference2 = abs(experimentData2.enrollmentDate.timeIntervalSince(decodedExperiments?[subfeatureName2]?.enrollmentDate ?? Date())) + XCTAssertEqual(decodedExperiments?[subfeatureName1]?.cohort, experimentData1.cohort) + XCTAssertLessThanOrEqual(timeDifference1, 1.0) + XCTAssertEqual(decodedExperiments?[subfeatureName2]?.cohort, experimentData2.cohort) + XCTAssertLessThanOrEqual(timeDifference2, 1.0) + } +} + +class MockLocalDataStore: LocalDataStoring { + var data: Data? + + func data(forKey defaultName: String) -> Data? { + return data + } + + func set(_ value: Any?, forKey defaultName: String) { + data = value as? Data + } +} diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift index f4406afef..fac814b1c 100644 --- a/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift @@ -65,6 +65,9 @@ class PrivacyConfigurationDataTests: XCTestCase { XCTAssertEqual(subfeatures["disabledSubfeature"]?.state, "disabled") XCTAssertEqual(subfeatures["minSupportedSubfeature"]?.minSupportedVersion, "1.36.0") XCTAssertEqual(subfeatures["enabledSubfeature"]?.state, "enabled") + XCTAssertEqual(subfeatures["enabledSubfeature"]?.cohorts?.count, 3) + XCTAssertEqual(subfeatures["enabledSubfeature"]?.cohorts?[0].name, "myExperimentControl") + XCTAssertEqual(subfeatures["enabledSubfeature"]?.cohorts?[0].weight, 1) XCTAssertEqual(subfeatures["internalSubfeature"]?.state, "internal") } else { XCTFail("Could not parse subfeatures") diff --git a/Tests/BrowserServicesKitTests/Resources/privacy-config-example.json b/Tests/BrowserServicesKitTests/Resources/privacy-config-example.json index 2728eaf55..3fd5be5a5 100644 --- a/Tests/BrowserServicesKitTests/Resources/privacy-config-example.json +++ b/Tests/BrowserServicesKitTests/Resources/privacy-config-example.json @@ -170,7 +170,22 @@ "minSupportedVersion": "1.36.0" }, "enabledSubfeature": { - "state": "enabled" + "state": "enabled", + "description": "A description of the sub-feature", + "cohorts": [ + { + "name": "myExperimentControl", + "weight": 1 + }, + { + "name": "myExperimentBlue", + "weight": 1 + }, + { + "name": "myExperimentRed", + "weight": 1 + } + ] }, "internalSubfeature": { "state": "internal"