diff --git a/Sources/BrowserServicesKit/FeatureFlagger/ExperimentCohortsManager.swift b/Sources/BrowserServicesKit/FeatureFlagger/ExperimentCohortsManager.swift new file mode 100644 index 000000000..60a03f8c4 --- /dev/null +++ b/Sources/BrowserServicesKit/FeatureFlagger/ExperimentCohortsManager.swift @@ -0,0 +1,144 @@ +// +// 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 + +public typealias CohortID = String +public typealias SubfeatureID = String +public typealias ParentFeatureID = String +public typealias Experiments = [String: ExperimentData] + +public struct ExperimentSubfeature { + let parentID: ParentFeatureID + let subfeatureID: SubfeatureID + let cohorts: [PrivacyConfigurationData.Cohort] +} + +public struct ExperimentData: Codable, Equatable { + public let parentID: ParentFeatureID + public let cohortID: CohortID + public let enrollmentDate: Date +} + +public protocol ExperimentCohortsManaging { + /// Retrieves all the experiments a user is enrolled in + var experiments: Experiments? { get } + + /// Resolves the cohort for a given experiment subfeature. + /// + /// This method determines whether the user is currently assigned to a valid cohort + /// for the specified experiment. If the assigned cohort is valid (i.e., it matches + /// one of the experiment's defined cohorts), the method returns the assigned cohort. + /// Otherwise, the invalid cohort is removed, and a new cohort is assigned if + /// `allowCohortReassignment` is `true`. + /// + /// - Parameters: + /// - experiment: The `ExperimentSubfeature` representing the experiment and its associated cohorts. + /// - allowCohortReassignment: A Boolean value indicating whether cohort assignment is allowed + /// if the user is not already assigned to a valid cohort. + /// + /// - Returns: The valid `CohortID` assigned to the user for the experiment, or `nil` + /// if no valid cohort exists and `allowCohortReassignment` is `false`. + /// + /// - Behavior: + /// 1. Retrieves the currently assigned cohort for the experiment using the `subfeatureID`. + /// 2. Validates if the assigned cohort exists within the experiment's cohort list: + /// - If valid, the assigned cohort is returned. + /// - If invalid, the cohort is removed from storage. + /// 3. If cohort assignment is enabled (`allowCohortReassignment` is `true`), a new cohort + /// is assigned based on the experiment's cohort weights and saved in storage. + /// - Cohort assignment is probabilistic, determined by the cohort weights. + /// + func resolveCohort(for experiment: ExperimentSubfeature, allowCohortReassignment: Bool) -> CohortID? +} + +public class ExperimentCohortsManager: ExperimentCohortsManaging { + + private var store: ExperimentsDataStoring + private let randomizer: (Range) -> Double + private let queue = DispatchQueue(label: "com.ExperimentCohortsManager.queue") + + public var experiments: Experiments? { + get { + queue.sync { + store.experiments + } + } + } + + public init(store: ExperimentsDataStoring = ExperimentsDataStore(), randomizer: @escaping (Range) -> Double = Double.random(in:)) { + self.store = store + self.randomizer = randomizer + } + + public func resolveCohort(for experiment: ExperimentSubfeature, allowCohortReassignment: Bool) -> CohortID? { + queue.sync { + let assignedCohort = cohort(for: experiment.subfeatureID) + if experiment.cohorts.contains(where: { $0.name == assignedCohort }) { + return assignedCohort + } + removeCohort(from: experiment.subfeatureID) + return allowCohortReassignment ? assignCohort(to: experiment) : nil + } + } +} + +// MARK: Helper functions +extension ExperimentCohortsManager { + + private 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.. CohortID? { + guard let experiments = store.experiments else { return nil } + return experiments[subfeatureID]?.cohortID + } + + private func enrollmentDate(for subfeatureID: SubfeatureID) -> Date? { + guard let experiments = store.experiments else { return nil } + return experiments[subfeatureID]?.enrollmentDate + } + + private func removeCohort(from subfeatureID: SubfeatureID) { + guard var experiments = store.experiments else { return } + experiments.removeValue(forKey: subfeatureID) + store.experiments = experiments + } + + private func saveCohort(_ cohort: CohortID, in experimentID: SubfeatureID, parentID: ParentFeatureID) { + var experiments = store.experiments ?? Experiments() + let experimentData = ExperimentData(parentID: parentID, cohortID: cohort, enrollmentDate: Date()) + experiments[experimentID] = experimentData + store.experiments = experiments + } +} diff --git a/Sources/BrowserServicesKit/PrivacyConfig/ExperimentsDataStore.swift b/Sources/BrowserServicesKit/FeatureFlagger/ExperimentsDataStore.swift similarity index 86% rename from Sources/BrowserServicesKit/PrivacyConfig/ExperimentsDataStore.swift rename to Sources/BrowserServicesKit/FeatureFlagger/ExperimentsDataStore.swift index bdf82819a..c99184df6 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/ExperimentsDataStore.swift +++ b/Sources/BrowserServicesKit/FeatureFlagger/ExperimentsDataStore.swift @@ -18,16 +18,16 @@ import Foundation -protocol ExperimentsDataStoring { +public protocol ExperimentsDataStoring { var experiments: Experiments? { get set } } -protocol LocalDataStoring { +public protocol LocalDataStoring { func data(forKey defaultName: String) -> Data? func set(_ value: Any?, forKey defaultName: String) } -struct ExperimentsDataStore: ExperimentsDataStoring { +public struct ExperimentsDataStore: ExperimentsDataStoring { private enum Constants { static let experimentsDataKey = "ExperimentsData" @@ -36,13 +36,13 @@ struct ExperimentsDataStore: ExperimentsDataStoring { private let decoder = JSONDecoder() private let encoder = JSONEncoder() - init(localDataStoring: LocalDataStoring = UserDefaults.standard) { + public init(localDataStoring: LocalDataStoring = UserDefaults.standard) { self.localDataStoring = localDataStoring encoder.dateEncodingStrategy = .secondsSince1970 decoder.dateDecodingStrategy = .secondsSince1970 } - var experiments: Experiments? { + public var experiments: Experiments? { get { guard let savedData = localDataStoring.data(forKey: Constants.experimentsDataKey) else { return nil } return try? decoder.decode(Experiments.self, from: savedData) diff --git a/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagger.swift b/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagger.swift index d6f5fdee4..3ecb53d1e 100644 --- a/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagger.swift +++ b/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagger.swift @@ -18,6 +18,8 @@ import Foundation +public protocol FlagCohort: RawRepresentable, CaseIterable where RawValue == CohortID {} + /// This protocol defines a common interface for feature flags managed by FeatureFlagger. /// /// It should be implemented by the feature flag type in client apps. @@ -53,7 +55,43 @@ public protocol FeatureFlagDescribing: CaseIterable { /// case .sync: /// return .disabled /// case .cookieConsent: - /// return .internalOnly + /// return .internalOnly() + /// case .credentialsAutofill: + /// return .remoteDevelopment(.subfeature(AutofillSubfeature.credentialsAutofill)) + /// case .duckPlayer: + /// return .remoteReleasable(.feature(.duckPlayer)) + /// } + /// } + /// ``` + var source: FeatureFlagSource { get } +} + +/// This protocol defines a common interface for experiment feature flags managed by FeatureFlagger. +/// +/// It should be implemented by the feature flag type in client apps. +/// +public protocol FeatureFlagExperimentDescribing { + + /// Returns a string representation of the flag + var rawValue: String { get } + + /// Defines the source of the experiment feature flag, which corresponds to + /// where the final flag value should come from. + /// + /// Example client implementation: + /// + /// ``` + /// public enum FeatureFlag: FeatureFlagDescribing { + /// case sync + /// case autofill + /// case cookieConsent + /// case duckPlayer + /// + /// var source: FeatureFlagSource { + /// case .sync: + /// return .disabled + /// case .cookieConsent: + /// return .internalOnly(cohort) /// case .credentialsAutofill: /// return .remoteDevelopment(.subfeature(AutofillSubfeature.credentialsAutofill)) /// case .duckPlayer: @@ -62,6 +100,29 @@ public protocol FeatureFlagDescribing: CaseIterable { /// } /// ``` var source: FeatureFlagSource { get } + + /// Represents the possible groups or variants within an experiment. + /// + /// The `Cohort` type is used to define user groups or test variations for feature + /// experimentation. Each cohort typically corresponds to a specific behavior or configuration + /// applied to a subset of users. For example, in an A/B test, you might define cohorts such as + /// `control` and `treatment`. + /// + /// Each cohort must conform to the `CohortEnum` protocol, which ensures that the cohort type + /// is an `enum` with `String` raw values and provides access to all possible cases + /// through `CaseIterable`. + /// + /// Example: + /// ``` + /// public enum AutofillCohorts: String, CohortEnum { + /// case control + /// case treatment + /// } + /// ``` + /// + /// The `Cohort` type allows dynamic resolution of cohorts by their raw `String` value, + /// making it easy to map user configurations to specific cohort groups. + associatedtype CohortType: FlagCohort } public enum FeatureFlagSource { @@ -69,7 +130,7 @@ public enum FeatureFlagSource { case disabled /// Enabled for internal users only. Cannot be toggled remotely - case internalOnly + case internalOnly((any FlagCohort)? = nil) /// Toggled remotely using PrivacyConfiguration but only for internal users. Otherwise, disabled. case remoteDevelopment(PrivacyConfigFeatureLevel) @@ -107,6 +168,44 @@ public protocol FeatureFlagger: AnyObject { /// when the non-overridden feature flag value is required. /// func isFeatureOn(for featureFlag: Flag, allowOverride: Bool) -> Bool + + /// Retrieves the cohort for a feature flag if the feature is enabled. + /// + /// This method determines the source of the feature flag and evaluates its eligibility based on + /// the user's internal status and the privacy configuration. It supports different sources, such as + /// disabled features, internal-only features, and remotely toggled features. + /// + /// - Parameter featureFlag: A feature flag conforming to `FeatureFlagDescribing`. + /// + /// - Returns: The `CohortID` associated with the feature flag, or `nil` if the feature is disabled or + /// does not meet the eligibility criteria. + /// + /// - Behavior: + /// - For `.disabled`: Returns `nil`. + /// - For `.internalOnly`: Returns the cohort if the user is an internal user. + /// - For `.remoteDevelopment` and `.remoteReleasable`: + /// - If the feature is a subfeature, resolves its cohort using `getCohortIfEnabled(_ subfeature:)`. + /// - Returns `nil` if the user is not eligible. + /// + func getCohortIfEnabled(for featureFlag: Flag) -> (any FlagCohort)? + + /// Retrieves all active experiments currently assigned to the user. + /// + /// This method iterates over the experiments stored in the `ExperimentManager` and checks their state + /// against the current `PrivacyConfiguration`. If an experiment's state is enabled or disabled due to + /// a target mismatch, and its assigned cohort matches the resolved cohort, it is considered active. + /// + /// - Returns: A dictionary of active experiments where the key is the experiment's subfeature ID, + /// and the value is the associated `ExperimentData`. + /// + /// - Behavior: + /// 1. Fetches all enrolled experiments from the `ExperimentManager`. + /// 2. For each experiment: + /// - Retrieves its state from the `PrivacyConfiguration`. + /// - Validates its assigned cohort using `resolveCohort` in the `ExperimentManager`. + /// 3. If the experiment passes validation, it is added to the result dictionary. + /// + func getAllActiveExperiments() -> Experiments } public extension FeatureFlagger { @@ -126,14 +225,17 @@ public class DefaultFeatureFlagger: FeatureFlagger { public let internalUserDecider: InternalUserDecider public let privacyConfigManager: PrivacyConfigurationManaging + private let experimentManager: ExperimentCohortsManaging? public let localOverrides: FeatureFlagLocalOverriding? public init( internalUserDecider: InternalUserDecider, - privacyConfigManager: PrivacyConfigurationManaging + privacyConfigManager: PrivacyConfigurationManaging, + experimentManager: ExperimentCohortsManaging? ) { self.internalUserDecider = internalUserDecider self.privacyConfigManager = privacyConfigManager + self.experimentManager = experimentManager self.localOverrides = nil } @@ -141,11 +243,13 @@ public class DefaultFeatureFlagger: FeatureFlagger { internalUserDecider: InternalUserDecider, privacyConfigManager: PrivacyConfigurationManaging, localOverrides: FeatureFlagLocalOverriding, + experimentManager: ExperimentCohortsManaging?, for: Flag.Type ) { self.internalUserDecider = internalUserDecider self.privacyConfigManager = privacyConfigManager self.localOverrides = localOverrides + self.experimentManager = experimentManager localOverrides.featureFlagger = self // Clear all overrides if not an internal user @@ -173,6 +277,58 @@ public class DefaultFeatureFlagger: FeatureFlagger { } } + public func getAllActiveExperiments() -> Experiments { + guard let enrolledExperiments = experimentManager?.experiments else { return [:] } + var activeExperiments = [String: ExperimentData]() + let config = privacyConfigManager.privacyConfig + + for (subfeatureID, experimentData) in enrolledExperiments { + let state = config.stateFor(subfeatureID: subfeatureID, parentFeatureID: experimentData.parentID) + guard state == .enabled || state == .disabled(.targetDoesNotMatch) else { continue } + let cohorts = config.cohorts(subfeatureID: subfeatureID, parentFeatureID: experimentData.parentID) ?? [] + let experimentSubfeature = ExperimentSubfeature(parentID: experimentData.parentID, subfeatureID: subfeatureID, cohorts: cohorts) + + if experimentManager?.resolveCohort(for: experimentSubfeature, allowCohortReassignment: false) == experimentData.cohortID { + activeExperiments[subfeatureID] = experimentData + } + } + return activeExperiments + } + + public func getCohortIfEnabled(for featureFlag: Flag) -> (any FlagCohort)? { + switch featureFlag.source { + case .disabled: + return nil + case .internalOnly(let cohort): + return cohort + case .remoteReleasable(let featureType), + .remoteDevelopment(let featureType) where internalUserDecider.isInternalUser: + if case .subfeature(let subfeature) = featureType { + if let resolvedCohortID = getCohortIfEnabled(subfeature) { + return Flag.CohortType.allCases.first { return $0.rawValue == resolvedCohortID } + } + } + return nil + default: + return nil + } + } + + private func getCohortIfEnabled(_ subfeature: any PrivacySubfeature) -> CohortID? { + let config = privacyConfigManager.privacyConfig + let featureState = config.stateFor(subfeature) + let cohorts = config.cohorts(for: subfeature) + let experiment = ExperimentSubfeature(parentID: subfeature.parent.rawValue, subfeatureID: subfeature.rawValue, cohorts: cohorts ?? []) + switch featureState { + case .enabled: + return experimentManager?.resolveCohort(for: experiment, allowCohortReassignment: true) + case .disabled(.targetDoesNotMatch): + return experimentManager?.resolveCohort(for: experiment, allowCohortReassignment: false) + default: + return nil + } + } + private func isEnabled(_ featureType: PrivacyConfigFeatureLevel) -> Bool { switch featureType { case .feature(let feature): diff --git a/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift b/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift index 89e64093d..2390aff4d 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift @@ -33,19 +33,23 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { private let locallyUnprotected: DomainsProtectionStore private let internalUserDecider: InternalUserDecider private let userDefaults: UserDefaults + private let locale: Locale private let installDate: Date? + static let experimentManagerQueue = DispatchQueue(label: "com.experimentManager.queue") public init(data: PrivacyConfigurationData, identifier: String, localProtection: DomainsProtectionStore, internalUserDecider: InternalUserDecider, userDefaults: UserDefaults = UserDefaults(), + locale: Locale = Locale.current, installDate: Date? = nil) { self.data = data self.identifier = identifier self.locallyUnprotected = localProtection self.internalUserDecider = internalUserDecider self.userDefaults = userDefaults + self.locale = locale self.installDate = installDate } @@ -137,13 +141,14 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { } } - private func isRolloutEnabled(subfeature: any PrivacySubfeature, + private func isRolloutEnabled(subfeatureID: SubfeatureID, + parentID: ParentFeatureID, rolloutSteps: [PrivacyConfigurationData.PrivacyFeature.Feature.RolloutStep], randomizer: (Range) -> Double) -> Bool { // Empty rollouts should be default enabled guard !rolloutSteps.isEmpty else { return true } - let defsPrefix = "config.\(subfeature.parent.rawValue).\(subfeature.rawValue)" + let defsPrefix = "config.\(parentID).\(subfeatureID)" if userDefaults.bool(forKey: "\(defsPrefix).\(Constants.enabledKey)") { return true } @@ -182,7 +187,6 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { public func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> Bool { - switch stateFor(subfeature, versionProvider: versionProvider, randomizer: randomizer) { case .enabled: return true @@ -191,17 +195,30 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { } } - public func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + public func stateFor(_ subfeature: any PrivacySubfeature, + versionProvider: AppVersionProvider, + randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + guard let subfeatureData = subfeatures(for: subfeature.parent)[subfeature.rawValue] else { + return .disabled(.featureMissing) + } - let parentState = stateFor(featureKey: subfeature.parent, versionProvider: versionProvider) - guard case .enabled = parentState else { return parentState } + return stateFor(subfeatureID: subfeature.rawValue, subfeatureData: subfeatureData, parentFeature: subfeature.parent, versionProvider: versionProvider, randomizer: randomizer) + } - let subfeatures = subfeatures(for: subfeature.parent) - let subfeatureData = subfeatures[subfeature.rawValue] + private func stateFor(subfeatureID: SubfeatureID, + subfeatureData: PrivacyConfigurationData.PrivacyFeature.Feature, + parentFeature: PrivacyFeature, + versionProvider: AppVersionProvider, + randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + // Step 1: Check parent feature state + let parentState = stateFor(featureKey: parentFeature, versionProvider: versionProvider) + guard case .enabled = parentState else { return parentState } - let satisfiesMinVersion = satisfiesMinVersion(subfeatureData?.minSupportedVersion, versionProvider: versionProvider) + // Step 2: Check version + let satisfiesMinVersion = satisfiesMinVersion(subfeatureData.minSupportedVersion, versionProvider: versionProvider) - switch subfeatureData?.state { + // Step 3: Check sub-feature state + switch subfeatureData.state { case PrivacyConfigurationData.State.enabled: guard satisfiesMinVersion else { return .disabled(.appVersionNotSupported) } case PrivacyConfigurationData.State.internal: @@ -210,15 +227,31 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { default: return .disabled(.disabledInConfig) } - // Handle Rollouts - if let rollout = subfeatureData?.rollout, - !isRolloutEnabled(subfeature: subfeature, rolloutSteps: rollout.steps, randomizer: randomizer) { + // Step 4: Handle Rollouts + if let rollout = subfeatureData.rollout, + !isRolloutEnabled(subfeatureID: subfeatureID, parentID: parentFeature.rawValue, rolloutSteps: rollout.steps, randomizer: randomizer) { return .disabled(.stillInRollout) } + // Step 5: Check Targets + return checkTargets(subfeatureData) + } + + private func checkTargets(_ subfeatureData: PrivacyConfigurationData.PrivacyFeature.Feature?) -> PrivacyConfigurationFeatureState { + // Check Targets + if let targets = subfeatureData?.targets, !matchTargets(targets: targets){ + return .disabled(.targetDoesNotMatch) + } return .enabled } + private func matchTargets(targets: [PrivacyConfigurationData.PrivacyFeature.Feature.Target]) -> Bool { + targets.contains { target in + (target.localeCountry == nil || target.localeCountry == locale.regionCode) && + (target.localeLanguage == nil || target.localeLanguage == locale.languageCode) + } + } + private func subfeatures(for feature: PrivacyFeature) -> PrivacyConfigurationData.PrivacyFeature.Features { return data.features[feature.rawValue]?.features ?? [:] } @@ -247,7 +280,7 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { guard let domain = domain else { return true } return !isTempUnprotected(domain: domain) && !isUserUnprotected(domain: domain) && - !isInExceptionList(domain: domain, forFeature: .contentBlocking) + !isInExceptionList(domain: domain, forFeature: .contentBlocking) } public func isUserUnprotected(domain: String?) -> Bool { @@ -297,7 +330,25 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { public func userDisabledProtection(forDomain domain: String) { locallyUnprotected.disableProtection(forDomain: domain.punycodeEncodedHostname.lowercased()) } +} +extension AppPrivacyConfiguration { + + public func stateFor(subfeatureID: SubfeatureID, parentFeatureID: ParentFeatureID, versionProvider: AppVersionProvider, + randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + guard let parentFeature = PrivacyFeature(rawValue: parentFeatureID) else { return .disabled(.featureMissing) } + guard let subfeatureData = subfeatures(for: parentFeature)[subfeatureID] else { return .disabled(.featureMissing) } + return stateFor(subfeatureID: subfeatureID, subfeatureData: subfeatureData, parentFeature: parentFeature, versionProvider: versionProvider, randomizer: randomizer) + } + + public func cohorts(for subfeature: any PrivacySubfeature) -> [PrivacyConfigurationData.Cohort]? { + subfeatures(for: subfeature.parent)[subfeature.rawValue]?.cohorts + } + + public func cohorts(subfeatureID: SubfeatureID, parentFeatureID: ParentFeatureID) -> [PrivacyConfigurationData.Cohort]? { + guard let parentFeature = PrivacyFeature(rawValue: parentFeatureID) else { return nil } + return subfeatures(for: parentFeature)[subfeatureID]?.cohorts + } } extension Array where Element == String { diff --git a/Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift b/Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift deleted file mode 100644 index abd01290b..000000000 --- a/Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// 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..) -> Double) -> PrivacyConfigurationFeatureState + + func cohorts(for subfeature: any PrivacySubfeature) -> [PrivacyConfigurationData.Cohort]? + + func cohorts(subfeatureID: SubfeatureID, parentFeatureID: ParentFeatureID) -> [PrivacyConfigurationData.Cohort]? } public extension PrivacyConfiguration { @@ -120,4 +129,9 @@ public extension PrivacyConfiguration { func stateFor(_ subfeature: any PrivacySubfeature, randomizer: (Range) -> Double = Double.random(in:)) -> PrivacyConfigurationFeatureState { return stateFor(subfeature, versionProvider: AppVersionProvider(), randomizer: randomizer) } + + func stateFor(subfeatureID: SubfeatureID, parentFeatureID: ParentFeatureID, randomizer: (Range) -> Double = Double.random(in:)) -> PrivacyConfigurationFeatureState { + return stateFor(subfeatureID: subfeatureID, parentFeatureID: parentFeatureID, versionProvider: AppVersionProvider(), randomizer: randomizer) + } + } diff --git a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift index 7cbd2bc71..f6f91567f 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift @@ -136,6 +136,8 @@ public struct PrivacyConfigurationData { case minSupportedVersion case rollout case cohorts + case targets + case settings } public struct Rollout: Hashable { @@ -169,10 +171,27 @@ public struct PrivacyConfigurationData { } } + public struct Target { + enum CodingKeys: String { + case localeCountry + case localeLanguage + } + + public let localeCountry: String? + public let localeLanguage: String? + + public init(json: [String: Any]) { + self.localeCountry = json[CodingKeys.localeCountry.rawValue] as? String + self.localeLanguage = json[CodingKeys.localeLanguage.rawValue] as? String + } + } + public let state: FeatureState public let minSupportedVersion: FeatureSupportedVersion? public let rollout: Rollout? public let cohorts: [Cohort]? + public let targets: [Target]? + public let settings: String? public init?(json: [String: Any]) { guard let state = json[CodingKeys.state.rawValue] as? String else { @@ -194,6 +213,19 @@ public struct PrivacyConfigurationData { } else { cohorts = nil } + + if let targetData = json[CodingKeys.targets.rawValue] as? [[String: Any]] { + targets = targetData.compactMap { Target(json: $0) } + } else { + targets = nil + } + + if let settingsData = json[CodingKeys.settings.rawValue] { + let jsonData = try? JSONSerialization.data(withJSONObject: settingsData, options: []) + settings = jsonData != nil ? String(data: jsonData!, encoding: .utf8) : nil + } else { + settings = nil + } } } diff --git a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift index 219a01613..004729e70 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift @@ -55,6 +55,7 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { private let localProtection: DomainsProtectionStore private let errorReporting: EventMapping? private let installDate: Date? + private let locale: Locale public let internalUserDecider: InternalUserDecider @@ -110,12 +111,14 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { localProtection: DomainsProtectionStore, errorReporting: EventMapping? = nil, internalUserDecider: InternalUserDecider, + locale: Locale = Locale.current, installDate: Date? = nil ) { self.embeddedDataProvider = embeddedDataProvider self.localProtection = localProtection self.errorReporting = errorReporting self.internalUserDecider = internalUserDecider + self.locale = locale self.installDate = installDate reload(etag: fetchedETag, data: fetchedData) @@ -127,6 +130,7 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { identifier: fetchedData.etag, localProtection: localProtection, internalUserDecider: internalUserDecider, + locale: locale, installDate: installDate) } @@ -134,6 +138,7 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { identifier: embeddedConfigData.etag, localProtection: localProtection, internalUserDecider: internalUserDecider, + locale: locale, installDate: installDate) } diff --git a/Tests/BrokenSitePromptTests/PrivacyConfigurationManagerMock.swift b/Tests/BrokenSitePromptTests/PrivacyConfigurationManagerMock.swift index 9a3d7610c..29adc74fb 100644 --- a/Tests/BrokenSitePromptTests/PrivacyConfigurationManagerMock.swift +++ b/Tests/BrokenSitePromptTests/PrivacyConfigurationManagerMock.swift @@ -59,7 +59,7 @@ class PrivacyConfigurationMock: PrivacyConfiguration { return enabledSubfeaturesForVersions[subfeature.rawValue]?.contains(versionProvider.appVersion() ?? "") ?? false } - func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { + func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { if isSubfeatureEnabled(subfeature, versionProvider: versionProvider, randomizer: randomizer) { return .enabled } @@ -98,6 +98,18 @@ class PrivacyConfigurationMock: PrivacyConfiguration { return userUnprotected.contains(domain ?? "") } + func stateFor(subfeatureID: BrowserServicesKit.SubfeatureID, parentFeatureID: BrowserServicesKit.ParentFeatureID, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { + return .enabled + } + + func cohorts(for subfeature: any BrowserServicesKit.PrivacySubfeature) -> [BrowserServicesKit.PrivacyConfigurationData.Cohort]? { + return nil + } + + func cohorts(subfeatureID: BrowserServicesKit.SubfeatureID, parentFeatureID: BrowserServicesKit.ParentFeatureID) -> [BrowserServicesKit.PrivacyConfigurationData.Cohort]? { + return nil + } + } class PrivacyConfigurationManagerMock: PrivacyConfigurationManaging { diff --git a/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift b/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift index e1cb475cd..767250149 100644 --- a/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift +++ b/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift @@ -318,18 +318,26 @@ class PrivacyConfigurationMock: PrivacyConfiguration { return .enabled } - func isSubfeatureEnabled( - _ subfeature: any PrivacySubfeature, - versionProvider: AppVersionProvider, - randomizer: (Range) -> Double - ) -> Bool { + func isSubfeatureEnabled(_ subfeature: any BrowserServicesKit.PrivacySubfeature, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> Bool { true } - func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + func stateFor(_ subfeature: any BrowserServicesKit.PrivacySubfeature, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { return .enabled } + func stateFor(subfeatureID: BrowserServicesKit.SubfeatureID, parentFeatureID: BrowserServicesKit.ParentFeatureID, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { + return .enabled + } + + func cohorts(for subfeature: any BrowserServicesKit.PrivacySubfeature) -> [BrowserServicesKit.PrivacyConfigurationData.Cohort]? { + return nil + } + + func cohorts(subfeatureID: BrowserServicesKit.SubfeatureID, parentFeatureID: BrowserServicesKit.ParentFeatureID) -> [BrowserServicesKit.PrivacyConfigurationData.Cohort]? { + return nil + } + var identifier: String = "abcd" var version: String? = "123456789" var userUnprotectedDomains: [String] = [] diff --git a/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift b/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift index 31370ce4a..bc979233e 100644 --- a/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift +++ b/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift @@ -225,3 +225,11 @@ final class WebKitTestHelper { } } } + +class MockExperimentCohortsManager: ExperimentCohortsManaging { + func resolveCohort(for experiment: ExperimentSubfeature, allowCohortReassignment: Bool) -> CohortID? { + return nil + } + + var experiments: Experiments? +} diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift index 3eb2db17e..6f5fffbd4 100644 --- a/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift @@ -52,15 +52,18 @@ final class CapturingFeatureFlagOverriding: FeatureFlagLocalOverriding { final class DefaultFeatureFlaggerTests: XCTestCase { var internalUserDeciderStore: MockInternalUserStoring! + var experimentManager: MockExperimentManager! var overrides: CapturingFeatureFlagOverriding! override func setUp() { super.setUp() internalUserDeciderStore = MockInternalUserStoring() + experimentManager = MockExperimentManager() } override func tearDown() { internalUserDeciderStore = nil + experimentManager = nil super.tearDown() } @@ -72,9 +75,9 @@ final class DefaultFeatureFlaggerTests: XCTestCase { func testWhenInternalOnly_returnsIsInternalUserValue() { let featureFlagger = createFeatureFlagger() internalUserDeciderStore.isInternalUser = false - XCTAssertFalse(featureFlagger.isFeatureOn(for: FeatureFlagSource.internalOnly)) + XCTAssertFalse(featureFlagger.isFeatureOn(for: FeatureFlagSource.internalOnly())) internalUserDeciderStore.isInternalUser = true - XCTAssertTrue(featureFlagger.isFeatureOn(for: FeatureFlagSource.internalOnly)) + XCTAssertTrue(featureFlagger.isFeatureOn(for: FeatureFlagSource.internalOnly())) } func testWhenRemoteDevelopment_isNOTInternalUser_returnsFalse() { @@ -141,6 +144,128 @@ final class DefaultFeatureFlaggerTests: XCTestCase { assertFeatureFlagger(with: embeddedData, willReturn: false, for: sourceProvider) } + // MARK: - Experiments + + func testWhenGetCohortIfEnabled_andSourceDisabled_returnsNil() { + let featureFlagger = createFeatureFlagger() + let flag = FakeExperimentFlag(source: .disabled) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertNil(cohort) + } + + func estWhenGetCohortIfEnabled_andSourceInternal_returnsPassedCohort() { + let featureFlagger = createFeatureFlagger() + let flag = FakeExperimentFlag(source: .internalOnly(AutofillCohort.blue)) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertEqual(cohort?.rawValue, AutofillCohort.blue.rawValue) + } + + func testWhenGetCohortIfEnabled_andRemoteInternal_andInternalStateTrue_and_cohortAssigned_returnsAssignedCohort() { + internalUserDeciderStore.isInternalUser = true + let subfeature = AutofillSubfeature.credentialsAutofill + experimentManager.cohortToReturn = AutofillCohort.control.rawValue + let embeddedData = Self.embeddedConfig(autofillSubfeatureForState: (subfeature: subfeature, state: "enabled")) + + let flag = FakeExperimentFlag(source: .remoteDevelopment(.subfeature(AutofillSubfeature.credentialsAutofill))) + let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertEqual(cohort?.rawValue, AutofillCohort.control.rawValue) + } + + func testWhenGetCohortIfEnabled_andRemoteInternal_andInternalStateFalse_and_cohortAssigned_returnsNil() { + internalUserDeciderStore.isInternalUser = false + let subfeature = AutofillSubfeature.credentialsAutofill + experimentManager.cohortToReturn = AutofillCohort.control.rawValue + let embeddedData = Self.embeddedConfig(autofillSubfeatureForState: (subfeature: subfeature, state: "enabled")) + + let flag = FakeExperimentFlag(source: .remoteDevelopment(.subfeature(AutofillSubfeature.credentialsAutofill))) + let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertNil(cohort) + } + + func testWhenGetCohortIfEnabled_andRemoteInternal_andInternalStateTrue_and_cohortAssigned_andFeaturePassed_returnsNil() { + internalUserDeciderStore.isInternalUser = true + let subfeature = AutofillSubfeature.credentialsAutofill + experimentManager.cohortToReturn = AutofillCohort.control.rawValue + let embeddedData = Self.embeddedConfig(autofillSubfeatureForState: (subfeature: subfeature, state: "enabled")) + + let flag = FakeExperimentFlag(source: .remoteDevelopment(.feature(.autofill))) + let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertNil(cohort) + } + + func testWhenGetCohortIfEnabled_andRemoteInternal_andInternalStateTrue_and_cohortNotAssigned_returnsNil() { + internalUserDeciderStore.isInternalUser = true + let subfeature = AutofillSubfeature.credentialsAutofill + experimentManager.cohortToReturn = nil + let embeddedData = Self.embeddedConfig(autofillSubfeatureForState: (subfeature: subfeature, state: "enabled")) + + let flag = FakeExperimentFlag(source: .remoteDevelopment(.subfeature(AutofillSubfeature.credentialsAutofill))) + let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertNil(cohort) + } + + func testWhenGetCohortIfEnabled_andRemoteInternal_andInternalStateTrue_and_cohortAssignedButNorMatchingEnum_returnsNil() { + internalUserDeciderStore.isInternalUser = true + let subfeature = AutofillSubfeature.credentialsAutofill + experimentManager.cohortToReturn = "some" + let embeddedData = Self.embeddedConfig(autofillSubfeatureForState: (subfeature: subfeature, state: "enabled")) + + let flag = FakeExperimentFlag(source: .remoteDevelopment(.subfeature(AutofillSubfeature.credentialsAutofill))) + let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertNil(cohort) + } + + func testWhenGetCohortIfEnabled_andRemoteReleasable_and_cohortAssigned_returnsAssignedCohort() { + let subfeature = AutofillSubfeature.credentialsAutofill + experimentManager.cohortToReturn = AutofillCohort.control.rawValue + let embeddedData = Self.embeddedConfig(autofillSubfeatureForState: (subfeature: subfeature, state: "enabled")) + + let flag = FakeExperimentFlag(source: .remoteReleasable(.subfeature(AutofillSubfeature.credentialsAutofill))) + let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertEqual(cohort?.rawValue, AutofillCohort.control.rawValue) + } + + func testWhenGetCohortIfEnabled_andRemoteReleasable_and_cohortAssigned_andFeaturePassed_returnsNil() { + let subfeature = AutofillSubfeature.credentialsAutofill + experimentManager.cohortToReturn = AutofillCohort.control.rawValue + let embeddedData = Self.embeddedConfig(autofillSubfeatureForState: (subfeature: subfeature, state: "enabled")) + + let flag = FakeExperimentFlag(source: .remoteReleasable(.feature(.autofill))) + let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertNil(cohort) + } + + func testWhenGetCohortIfEnabled_andRemoteReleasable_and_cohortNotAssigned_andFeaturePassed_returnsNil() { + internalUserDeciderStore.isInternalUser = true + let subfeature = AutofillSubfeature.credentialsAutofill + experimentManager.cohortToReturn = nil + let embeddedData = Self.embeddedConfig(autofillSubfeatureForState: (subfeature: subfeature, state: "enabled")) + + let flag = FakeExperimentFlag(source: .remoteReleasable(.subfeature(AutofillSubfeature.credentialsAutofill))) + let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertNil(cohort) + } + + func testWhenGetCohortIfEnabled_andRemoteReleasable_and_cohortAssignedButNotMatchingEnum_returnsNil() { + internalUserDeciderStore.isInternalUser = true + let subfeature = AutofillSubfeature.credentialsAutofill + experimentManager.cohortToReturn = "some" + let embeddedData = Self.embeddedConfig(autofillSubfeatureForState: (subfeature: subfeature, state: "enabled")) + + let flag = FakeExperimentFlag(source: .remoteReleasable(.subfeature(AutofillSubfeature.credentialsAutofill))) + let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) + let cohort = featureFlagger.getCohortIfEnabled(for: flag) + XCTAssertNil(cohort) + } + // MARK: - Overrides func testWhenFeatureFlaggerIsInitializedWithLocalOverridesAndUserIsNotInternalThenAllFlagsAreCleared() throws { @@ -186,7 +311,7 @@ final class DefaultFeatureFlaggerTests: XCTestCase { localProtection: MockDomainsProtectionStore(), internalUserDecider: DefaultInternalUserDecider()) let internalUserDecider = DefaultInternalUserDecider(store: internalUserDeciderStore) - return DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfigManager: manager) + return DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfigManager: manager, experimentManager: experimentManager) } private func createFeatureFlaggerWithLocalOverrides(withMockedConfigData data: Data = DefaultFeatureFlaggerTests.embeddedConfig()) -> DefaultFeatureFlagger { @@ -203,6 +328,7 @@ final class DefaultFeatureFlaggerTests: XCTestCase { internalUserDecider: internalUserDecider, privacyConfigManager: manager, localOverrides: overrides, + experimentManager: nil, for: TestFeatureFlag.self ) } @@ -243,3 +369,25 @@ extension FeatureFlagSource: FeatureFlagDescribing { public var rawValue: String { "rawValue" } public var source: FeatureFlagSource { self } } + +class MockExperimentManager: ExperimentCohortsManaging { + var cohortToReturn: CohortID? + var experiments: BrowserServicesKit.Experiments? + + func resolveCohort(for experiment: BrowserServicesKit.ExperimentSubfeature, allowCohortReassignment: Bool) -> CohortID? { + return cohortToReturn + } +} + +private struct FakeExperimentFlag: FeatureFlagExperimentDescribing { + typealias CohortType = AutofillCohort + + var rawValue: String = "fake-experiment" + + var source: FeatureFlagSource +} + +private enum AutofillCohort: String, FlagCohort { + case control + case blue +} diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift new file mode 100644 index 000000000..48fc85355 --- /dev/null +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift @@ -0,0 +1,189 @@ +// +// 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 { + + let cohort1 = PrivacyConfigurationData.Cohort(json: ["name": "Cohort1", "weight": 1])! + let cohort2 = PrivacyConfigurationData.Cohort(json: ["name": "Cohort2", "weight": 0])! + let cohort3 = PrivacyConfigurationData.Cohort(json: ["name": "Cohort3", "weight": 2])! + let cohort4 = PrivacyConfigurationData.Cohort(json: ["name": "Cohort4", "weight": 0])! + + var mockStore: MockExperimentDataStore! + var experimentCohortsManager: ExperimentCohortsManager! + + let subfeatureName1 = "TestSubfeature1" + var experimentData1: ExperimentData! + + let subfeatureName2 = "TestSubfeature2" + var experimentData2: ExperimentData! + + let subfeatureName3 = "TestSubfeature3" + var experimentData3: ExperimentData! + + let subfeatureName4 = "TestSubfeature4" + var experimentData4: ExperimentData! + + let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + return encoder + }() + + override func setUp() { + super.setUp() + mockStore = MockExperimentDataStore() + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore + ) + + let expectedDate1 = Date() + experimentData1 = ExperimentData(parentID: "TestParent", cohortID: cohort1.name, enrollmentDate: expectedDate1) + + let expectedDate2 = Date().addingTimeInterval(60) + experimentData2 = ExperimentData(parentID: "TestParent", cohortID: cohort2.name, enrollmentDate: expectedDate2) + + let expectedDate3 = Date() + experimentData3 = ExperimentData(parentID: "TestParent", cohortID: cohort3.name, enrollmentDate: expectedDate3) + + let expectedDate4 = Date().addingTimeInterval(60) + experimentData4 = ExperimentData(parentID: "TestParent", cohortID: cohort4.name, enrollmentDate: expectedDate4) + } + + override func tearDown() { + mockStore = nil + experimentCohortsManager = nil + experimentData1 = nil + experimentData2 = nil + super.tearDown() + } + + func testExperimentReturnAssignedExperiments() { + // GIVEN + mockStore.experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] + + // WHEN + let experiments = experimentCohortsManager.experiments + + // THEN + XCTAssertEqual(experiments?.count, 2) + XCTAssertEqual(experiments?[subfeatureName1], experimentData1) + XCTAssertEqual(experiments?[subfeatureName2], experimentData2) + XCTAssertNil(experiments?[subfeatureName3]) + } + + func testCohortReturnsCohortIDIfExistsForMultipleSubfeatures() { + // GIVEN + mockStore.experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] + + // WHEN + let result1 = experimentCohortsManager.resolveCohort(for: ExperimentSubfeature(parentID: experimentData1.parentID, subfeatureID: subfeatureName1, cohorts: [cohort1, cohort2]), allowCohortReassignment: false) + let result2 = experimentCohortsManager.resolveCohort(for: ExperimentSubfeature(parentID: experimentData2.parentID, subfeatureID: subfeatureName2, cohorts: [cohort2, cohort3]), allowCohortReassignment: false) + + // THEN + XCTAssertEqual(result1, experimentData1.cohortID) + XCTAssertEqual(result2, experimentData2.cohortID) + } + + func testCohortAssignIfEnabledWhenNoCohortExists() { + // GIVEN + mockStore.experiments = [:] + let cohorts = [cohort1, cohort2] + let experiment = ExperimentSubfeature(parentID: experimentData1.parentID, subfeatureID: subfeatureName1, cohorts: cohorts) + + // WHEN + let result = experimentCohortsManager.resolveCohort(for: experiment, allowCohortReassignment: true) + + // THEN + XCTAssertNotNil(result) + XCTAssertEqual(result, experimentData1.cohortID) + } + + func testCohortDoesNotAssignIfAssignIfEnabledIsFalse() { + // GIVEN + mockStore.experiments = [:] + let cohorts = [cohort1, cohort2] + let experiment = ExperimentSubfeature(parentID: experimentData1.parentID, subfeatureID: subfeatureName1, cohorts: cohorts) + + // WHEN + let result = experimentCohortsManager.resolveCohort(for: experiment, allowCohortReassignment: false) + + // THEN + XCTAssertNil(result) + } + + func testCohortDoesNotAssignIfAssignIfEnabledIsTrueButNoCohortsAvailable() { + // GIVEN + mockStore.experiments = [:] + let experiment = ExperimentSubfeature(parentID: "TestParent", subfeatureID: "NonExistentSubfeature", cohorts: []) + + // WHEN + let result = experimentCohortsManager.resolveCohort(for: experiment, allowCohortReassignment: true) + + // THEN + XCTAssertNil(result) + } + + func testCohortReassignsCohortIfAssignedCohortDoesNotExistAndAssignIfEnabledIsTrue() { + // GIVEN + mockStore.experiments = [subfeatureName1: experimentData1] + + // WHEN + let result1 = experimentCohortsManager.resolveCohort(for: ExperimentSubfeature(parentID: experimentData1.parentID, subfeatureID: subfeatureName1, cohorts: [cohort2, cohort3]), allowCohortReassignment: true) + + // THEN + XCTAssertEqual(result1, experimentData3.cohortID) + } + + func testCohortDoesNotReassignsCohortIfAssignedCohortDoesNotExistAndAssignIfEnabledIsTrue() { + // GIVEN + mockStore.experiments = [subfeatureName1: experimentData1] + + // WHEN + let result1 = experimentCohortsManager.resolveCohort(for: ExperimentSubfeature(parentID: experimentData1.parentID, subfeatureID: subfeatureName1, cohorts: [cohort2, cohort3]), allowCohortReassignment: false) + + // THEN + XCTAssertNil(result1) + } + + func testCohortAssignsBasedOnWeight() { + // GIVEN + let experiment = ExperimentSubfeature(parentID: experimentData3.parentID, subfeatureID: subfeatureName3, cohorts: [cohort3, cohort4]) + + let randomizer: (Range) -> Double = { range in + return 1.5 + } + + experimentCohortsManager = ExperimentCohortsManager( + store: mockStore, + randomizer: randomizer + ) + + // WHEN + let result = experimentCohortsManager.resolveCohort(for: experiment, allowCohortReassignment: true) + + // THEN + XCTAssertEqual(result, experimentData3.cohortID) + } +} + +class MockExperimentDataStore: ExperimentsDataStoring { + var experiments: Experiments? +} diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentsDataStoreTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentsDataStoreTests.swift similarity index 80% rename from Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentsDataStoreTests.swift rename to Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentsDataStoreTests.swift index 0466155b0..77da51663 100644 --- a/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentsDataStoreTests.swift +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentsDataStoreTests.swift @@ -47,8 +47,8 @@ final class ExperimentsDataStoreTests: XCTestCase { func testExperimentsGetReturnsDecodedExperiments() { // GIVEN - let experimentData1 = ExperimentData(cohort: "TestCohort1", enrollmentDate: Date()) - let experimentData2 = ExperimentData(cohort: "TestCohort2", enrollmentDate: Date()) + let experimentData1 = ExperimentData(parentID: "parent", cohortID: "TestCohort1", enrollmentDate: Date()) + let experimentData2 = ExperimentData(parentID: "parent", cohortID: "TestCohort2", enrollmentDate: Date()) let experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] let encoder = JSONEncoder() @@ -62,17 +62,17 @@ final class ExperimentsDataStoreTests: XCTestCase { // 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) + XCTAssertEqual(result?[subfeatureName1]?.cohortID, experimentData1.cohortID) XCTAssertLessThanOrEqual(timeDifference1, 1.0) - XCTAssertEqual(result?[subfeatureName2]?.cohort, experimentData2.cohort) + XCTAssertEqual(result?[subfeatureName2]?.cohortID, experimentData2.cohortID) XCTAssertLessThanOrEqual(timeDifference2, 1.0) } func testExperimentsSetEncodesAndStoresData() throws { // GIVEN - let experimentData1 = ExperimentData(cohort: "TestCohort1", enrollmentDate: Date()) - let experimentData2 = ExperimentData(cohort: "TestCohort2", enrollmentDate: Date()) + let experimentData1 = ExperimentData(parentID: "parent", cohortID: "TestCohort1", enrollmentDate: Date()) + let experimentData2 = ExperimentData(parentID: "parent2", cohortID: "TestCohort2", enrollmentDate: Date()) let experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] // WHEN @@ -85,9 +85,11 @@ final class ExperimentsDataStoreTests: XCTestCase { 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) + XCTAssertEqual(decodedExperiments?[subfeatureName1]?.cohortID, experimentData1.cohortID) + XCTAssertEqual(decodedExperiments?[subfeatureName1]?.parentID, experimentData1.parentID) XCTAssertLessThanOrEqual(timeDifference1, 1.0) - XCTAssertEqual(decodedExperiments?[subfeatureName2]?.cohort, experimentData2.cohort) + XCTAssertEqual(decodedExperiments?[subfeatureName2]?.cohortID, experimentData2.cohortID) + XCTAssertEqual(decodedExperiments?[subfeatureName2]?.parentID, experimentData2.parentID) XCTAssertLessThanOrEqual(timeDifference2, 1.0) } } diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift index 5e2407ca1..5223ff059 100644 --- a/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift @@ -46,7 +46,7 @@ final class FeatureFlagLocalOverridesTests: XCTestCase { let internalUserDecider = DefaultInternalUserDecider(store: internalUserDeciderStore) let privacyConfig = MockPrivacyConfiguration() let privacyConfigManager = MockPrivacyConfigurationManager(privacyConfig: privacyConfig, internalUserDecider: internalUserDecider) - featureFlagger = DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfigManager: privacyConfigManager) + featureFlagger = DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfigManager: privacyConfigManager, experimentManager: nil) keyValueStore = MockKeyValueStore() actionHandler = CapturingFeatureFlagLocalOverridesHandler() diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift new file mode 100644 index 000000000..b6931ef22 --- /dev/null +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift @@ -0,0 +1,1228 @@ +// +// FeatureFlaggerExperimentsTests.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 + +struct CredentialsSavingFlag: FeatureFlagExperimentDescribing { + + typealias CohortType = Cohort + + var rawValue = "credentialSaving" + + var source: FeatureFlagSource = .remoteReleasable(.subfeature(AutofillSubfeature.credentialsSaving)) + + enum Cohort: String, FlagCohort { + case control + case blue + case red + } +} + +struct InlineIconCredentialsFlag: FeatureFlagExperimentDescribing { + + typealias CohortType = Cohort + + var rawValue = "inlineIconCredentials" + + var source: FeatureFlagSource = .remoteReleasable(.subfeature(AutofillSubfeature.inlineIconCredentials)) + + enum Cohort: String, FlagCohort { + case control + case blue + case green + } +} + +struct AccessCredentialManagementFlag: FeatureFlagExperimentDescribing { + + typealias CohortType = Cohort + + var rawValue = "accessCredentialManagement" + + var source: FeatureFlagSource = .remoteReleasable(.subfeature(AutofillSubfeature.accessCredentialManagement)) + + enum Cohort: String, FlagCohort { + case control + case blue + case green + } +} + +final class FeatureFlaggerExperimentsTests: XCTestCase { + + var featureJson: Data = "{}".data(using: .utf8)! + var mockEmbeddedData: MockEmbeddedDataProvider! + var mockStore: MockExperimentDataStore! + var experimentManager: ExperimentCohortsManager! + var manager: PrivacyConfigurationManager! + var locale: Locale! + var featureFlagger: FeatureFlagger! + + let subfeatureName = "credentialsSaving" + + override func setUp() { + locale = Locale(identifier: "fr_US") + mockEmbeddedData = MockEmbeddedDataProvider(data: featureJson, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + mockStore = MockExperimentDataStore() + experimentManager = ExperimentCohortsManager(store: mockStore) + manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + featureFlagger = DefaultFeatureFlagger(internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), privacyConfigManager: manager, experimentManager: experimentManager) + } + + override func tearDown() { + featureJson = "".data(using: .utf8)! + mockEmbeddedData = nil + mockStore = nil + experimentManager = nil + manager = nil + } + + func testCohortOnlyAssignedWhenCallingStateForSubfeature() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + let config = manager.privacyConfig + + // we haven't called getCohortIfEnabled yet, so cohorts should not be yet assigned + XCTAssertNil(mockStore.experiments) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + // we call isSubfeatureEnabled() hould not be assigned either + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .enabled) + XCTAssertNil(mockStore.experiments) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + // we call getCohortIfEnabled(cohort), then we should assign cohort + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + } + + func testRemoveAllCohortsRemotelyRemovesAssignedCohort() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + var config = manager.privacyConfig + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // remove blue cohort + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + config = manager.privacyConfig + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // remove all remaining cohorts + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2 + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + config = manager.privacyConfig + + XCTAssertNil(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())) + XCTAssertTrue(mockStore.experiments?.isEmpty ?? false) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + } + + func testRemoveAssignedCohortsRemotelyRemovesAssignedCohortAndTriesToReassign() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "2", data: featureJson) + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // remove blue cohort + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "red", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "2", data: featureJson) + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.red.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "red") + } + + func testDisablingFeatureDisablesCohort() { + // Initially subfeature for both cohorts is disabled + XCTAssertNil(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())) + XCTAssertNil(mockStore.experiments) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + // When features with cohort the cohort with weight 1 is enabled + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // If the subfeature is then disabled isSubfeatureEnabled should return false + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "disabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + + XCTAssertNil(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // If the subfeature is parent feature disabled isSubfeatureEnabled should return false + featureJson = + """ + { + "features": { + "autofill": { + "state": "disabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + + XCTAssertNil(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + } + + func testCohortsAndTargetsInteraction() { + func featureJson(country: String, language: String) -> Data { + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeLanguage": "\(language)", + "localeCountry": "\(country)" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + } + manager.reload(etag: "", data: featureJson(country: "FR", language: "fr")) + + XCTAssertNil(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())) + XCTAssertNil(mockStore.experiments) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + manager.reload(etag: "", data: featureJson(country: "US", language: "en")) + XCTAssertNil(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())) + XCTAssertNil(mockStore.experiments) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + manager.reload(etag: "", data: featureJson(country: "US", language: "fr")) + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // once cohort is assigned, changing targets shall not affect feature state + manager.reload(etag: "", data: featureJson(country: "IT", language: "it")) + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + let featureJson2 = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "FR" + } + ], + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson2) + + XCTAssertNil(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())) + XCTAssertTrue(mockStore.experiments?.isEmpty ?? false) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + // re-populate experiment to re-assign new cohort, should not be assigned as it has wrong targets + manager.reload(etag: "", data: featureJson(country: "IT", language: "it")) + XCTAssertNil(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())) + XCTAssertTrue(mockStore.experiments?.isEmpty ?? false) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + } + + func testChangeRemoteCohortsAfterAssignmentShouldNoop() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // changing targets should not change cohort assignment + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "IT" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // changing cohort weight should not change current assignment + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 0 + }, + { + "name": "blue", + "weight": 1 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // adding cohorts should not change current assignment + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 1 + }, + { + "name": "red", + "weight": 1 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + } + + func testEnrollmentDate() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + let config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertTrue(mockStore.experiments?.isEmpty ?? true) + XCTAssertNil(experimentManager.cohort(for: subfeatureName), "control") + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + let currentTime = Date().timeIntervalSince1970 + let enrollmentTime = mockStore.experiments?[subfeatureName]?.enrollmentDate.timeIntervalSince1970 + + XCTAssertNotNil(enrollmentTime) + if let enrollmentTime = enrollmentTime { + let tolerance: TimeInterval = 60 // 1 minute in seconds + XCTAssertEqual(currentTime, enrollmentTime, accuracy: tolerance) + } + } + + func testRollbackCohortExperiments() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + }, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + var config = manager.privacyConfig + clearRolloutData(feature: "autofill", subFeature: "credentialsSaving") + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "rollout": { + "steps": [ + { + "percent": 0 + } + ] + }, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + } + + func testCohortEnabledAndStopEnrollmentAndRhenRollBack() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + var config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // Stop enrollment, should keep assigned cohorts + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 0 + }, + { + "name": "blue", + "weight": 1 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.control.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // remove control, should re-allocate to blue + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "blue", + "weight": 1 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.CohortType.blue.rawValue) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "blue") + } + + private func clearRolloutData(feature: String, subFeature: String) { + UserDefaults().set(nil, forKey: "config.\(feature).\(subFeature).enabled") + UserDefaults().set(nil, forKey: "config.\(feature).\(subFeature).lastRolloutCount") + } + + func testAllActiveExperimentsEmptyIfNoAssignedExperiment() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + + let activeExperiments = featureFlagger.getAllActiveExperiments() + XCTAssertTrue(activeExperiments.isEmpty) + XCTAssertNil(mockStore.experiments) + } + + func testAllActiveExperimentsReturnsOnlyActiveExperiments() { + var featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + }, + "inlineIconCredentials": { + "state": "enabled", + "minSupportedVersion": 1, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 0 + }, + { + "name": "green", + "weight": 1 + } + ] + }, + "accessCredentialManagement": { + "state": "enabled", + "minSupportedVersion": 3, + "targets": [ + { + "localeCountry": "CA" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "green", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + manager.reload(etag: "foo", data: featureJson) + + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: CredentialsSavingFlag())?.rawValue, CredentialsSavingFlag.Cohort.control.rawValue) + XCTAssertEqual(featureFlagger.getCohortIfEnabled(for: InlineIconCredentialsFlag())?.rawValue, InlineIconCredentialsFlag.Cohort.green.rawValue) + XCTAssertNil(featureFlagger.getCohortIfEnabled(for: AccessCredentialManagementFlag())) + + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: AutofillSubfeature.credentialsSaving.rawValue), "control") + XCTAssertEqual(experimentManager.cohort(for: AutofillSubfeature.inlineIconCredentials.rawValue), "green") + XCTAssertNil(experimentManager.cohort(for: AutofillSubfeature.accessCredentialManagement.rawValue)) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + + var activeExperiments = featureFlagger.getAllActiveExperiments() + XCTAssertEqual(activeExperiments.count, 2) + XCTAssertEqual(activeExperiments[AutofillSubfeature.credentialsSaving.rawValue]?.cohortID, "control") + XCTAssertEqual(activeExperiments[AutofillSubfeature.inlineIconCredentials.rawValue]?.cohortID, "green") + XCTAssertNil(activeExperiments[AutofillSubfeature.accessCredentialManagement.rawValue]) + + // When an assigned cohort is removed it's not part of active experiments + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "blue", + "weight": 1 + } + ] + }, + "inlineIconCredentials": { + "state": "enabled", + "minSupportedVersion": 1, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 0 + }, + { + "name": "green", + "weight": 1 + } + ] + }, + "accessCredentialManagement": { + "state": "enabled", + "minSupportedVersion": 3, + "targets": [ + { + "localeCountry": "CA" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "green", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + manager.reload(etag: "foo", data: featureJson) + + activeExperiments = featureFlagger.getAllActiveExperiments() + XCTAssertEqual(activeExperiments.count, 1) + XCTAssertNil(activeExperiments[AutofillSubfeature.credentialsSaving.rawValue]) + XCTAssertEqual(activeExperiments[AutofillSubfeature.inlineIconCredentials.rawValue]?.cohortID, "green") + XCTAssertNil(activeExperiments[AutofillSubfeature.accessCredentialManagement.rawValue]) + + // When feature disabled an assigned cohort it's not part of active experiments + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "blue", + "weight": 1 + } + ] + }, + "inlineIconCredentials": { + "state": "disabled", + "minSupportedVersion": 1, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 0 + }, + { + "name": "green", + "weight": 1 + } + ] + }, + "accessCredentialManagement": { + "state": "enabled", + "minSupportedVersion": 3, + "targets": [ + { + "localeCountry": "CA" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "green", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + manager.reload(etag: "foo", data: featureJson) + + activeExperiments = featureFlagger.getAllActiveExperiments() + XCTAssertTrue(activeExperiments.isEmpty) + } + +} diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/TestFeatureFlag.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/TestFeatureFlag.swift index 8c1a43ee7..8d8ec9929 100644 --- a/Tests/BrowserServicesKitTests/FeatureFlagging/TestFeatureFlag.swift +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/TestFeatureFlag.swift @@ -35,11 +35,11 @@ enum TestFeatureFlag: String, FeatureFlagDescribing { var source: FeatureFlagSource { switch self { case .nonOverridableFlag: - return .internalOnly + return .internalOnly() case .overridableFlagDisabledByDefault: return .disabled case .overridableFlagEnabledByDefault: - return .internalOnly + return .internalOnly() } } } diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/AppPrivacyConfigurationTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/AppPrivacyConfigurationTests.swift index 252a7b866..9388b14e9 100644 --- a/Tests/BrowserServicesKitTests/PrivacyConfig/AppPrivacyConfigurationTests.swift +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/AppPrivacyConfigurationTests.swift @@ -907,6 +907,186 @@ class AppPrivacyConfigurationTests: XCTestCase { XCTAssertEqual(configAfterUpdate.stateFor(AutofillSubfeature.credentialsSaving, randomizer: mockRandom(in:)), .disabled(.disabledInConfig)) } + let exampleSubfeatureEnabledWithTarget = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "targets": [ + { + "localeCountry": "US", + "localeLanguage": "fr" + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + func testWhenCheckingSubfeatureStateWithSubfeatureEnabledWhenTargetMatches_SubfeatureShouldBeEnabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureEnabledWithTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "fr_US") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .enabled) + } + + func testWhenCheckingSubfeatureStateWithSubfeatureEnabledWhenRegionDoesNotMatches_SubfeatureShouldBeDisabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureEnabledWithTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "fr_FR") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .disabled(.targetDoesNotMatch)) + } + + func testWhenCheckingSubfeatureStateWithSubfeatureEnabledWhenLanguageDoesNotMatches_SubfeatureShouldBeDisabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureEnabledWithTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "it_US") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .disabled(.targetDoesNotMatch)) + } + + let exampleSubfeatureDisabledWithTarget = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "disabled", + "targets": [ + { + "localeCountry": "US", + "localeLanguage": "fr" + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + func testWhenCheckingSubfeatureStateWithSubfeatureDisabledWhenTargetMatches_SubfeatureShouldBeDisabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureDisabledWithTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "fr_US") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .disabled(.disabledInConfig)) + } + + let exampleSubfeatureEnabledWithRolloutAndTarget = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "targets": [ + { + "localeCountry": "US", + "localeLanguage": "fr" + } + ], + "rollout": { + "steps": [{ + "percent": 5.0 + }] + } + } + } + } + } + } + """.data(using: .utf8)! + + func testWhenCheckingSubfeatureStateWithSubfeatureEnabledAndTargetMatchesWhenNotInRollout_SubfeatureShouldBeDisabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureEnabledWithRolloutAndTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "fr_US") + mockRandomValue = 7.0 + clearRolloutData(feature: "autofill", subFeature: "credentialsSaving") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, randomizer: mockRandom(in:))) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .disabled(.stillInRollout)) + } + + func testWhenCheckingSubfeatureStateWithSubfeatureEnabledAndTargetMatchesWhenInRollout_SubfeatureShouldBeEnabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureEnabledWithRolloutAndTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "fr_US") + mockRandomValue = 2.0 + clearRolloutData(feature: "autofill", subFeature: "credentialsSaving") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, randomizer: mockRandom(in:))) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .enabled) + } + let exampleEnabledSubfeatureWithRollout = """ { diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentCohortsManagerTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentCohortsManagerTests.swift deleted file mode 100644 index 518249560..000000000 --- a/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentCohortsManagerTests.swift +++ /dev/null @@ -1,266 +0,0 @@ -// -// 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/PrivacyConfigurationDataTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift index fac814b1c..08ecbec85 100644 --- a/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift @@ -68,6 +68,9 @@ class PrivacyConfigurationDataTests: XCTestCase { XCTAssertEqual(subfeatures["enabledSubfeature"]?.cohorts?.count, 3) XCTAssertEqual(subfeatures["enabledSubfeature"]?.cohorts?[0].name, "myExperimentControl") XCTAssertEqual(subfeatures["enabledSubfeature"]?.cohorts?[0].weight, 1) + XCTAssertEqual(subfeatures["enabledSubfeature"]?.targets?[0].localeCountry, "US") + XCTAssertEqual(subfeatures["enabledSubfeature"]?.targets?[0].localeLanguage, "fr") + XCTAssertEqual(subfeatures["enabledSubfeature"]?.settings, "{\"foo\":\"foo\\/value\",\"bar\":\"bar\\/value\"}") 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 3fd5be5a5..735c3914f 100644 --- a/Tests/BrowserServicesKitTests/Resources/privacy-config-example.json +++ b/Tests/BrowserServicesKitTests/Resources/privacy-config-example.json @@ -171,6 +171,16 @@ }, "enabledSubfeature": { "state": "enabled", + "targets": [ + { + "localeCountry": "US", + "localeLanguage": "fr" + } + ], + "settings": { + "foo": "foo/value", + "bar": "bar/value" + }, "description": "A description of the sub-feature", "cohorts": [ { diff --git a/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests b/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests index 6133e7d9d..a603ff9af 160000 --- a/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests +++ b/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests @@ -1 +1 @@ -Subproject commit 6133e7d9d9cd5f1b925cab1971b4d785dc639df7 +Subproject commit a603ff9af22ca3ff7ce2e7ffbfe18c447d9f23e8 diff --git a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift index a85dadc1e..388ad69b9 100644 --- a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift +++ b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift @@ -221,17 +221,29 @@ class MockPrivacyConfiguration: PrivacyConfiguration { var isSubfeatureEnabledCheck: ((any PrivacySubfeature) -> Bool)? - func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> Bool { + func isSubfeatureEnabled(_ subfeature: any BrowserServicesKit.PrivacySubfeature, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> Bool { isSubfeatureEnabledCheck?(subfeature) ?? false } - func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + func stateFor(_ subfeature: any BrowserServicesKit.PrivacySubfeature, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { if isSubfeatureEnabledCheck?(subfeature) == true { return .enabled } return .disabled(.disabledInConfig) } + func stateFor(subfeatureID: BrowserServicesKit.SubfeatureID, parentFeatureID: BrowserServicesKit.ParentFeatureID, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { + return .enabled + } + + func cohorts(for subfeature: any BrowserServicesKit.PrivacySubfeature) -> [BrowserServicesKit.PrivacyConfigurationData.Cohort]? { + return nil + } + + func cohorts(subfeatureID: BrowserServicesKit.SubfeatureID, parentFeatureID: BrowserServicesKit.ParentFeatureID) -> [BrowserServicesKit.PrivacyConfigurationData.Cohort]? { + return nil + } + var identifier: String = "abcd" var version: String? = "123456789" var userUnprotectedDomains: [String] = [] diff --git a/Tests/DDGSyncTests/Mocks/Mocks.swift b/Tests/DDGSyncTests/Mocks/Mocks.swift index 013123c6d..4745f155c 100644 --- a/Tests/DDGSyncTests/Mocks/Mocks.swift +++ b/Tests/DDGSyncTests/Mocks/Mocks.swift @@ -169,18 +169,26 @@ class MockPrivacyConfiguration: PrivacyConfiguration { return .enabled } - func isSubfeatureEnabled( - _ subfeature: any PrivacySubfeature, - versionProvider: AppVersionProvider, - randomizer: (Range) -> Double - ) -> Bool { + func isSubfeatureEnabled(_ subfeature: any BrowserServicesKit.PrivacySubfeature, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> Bool { true } - func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + func stateFor(_ subfeature: any BrowserServicesKit.PrivacySubfeature, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { return .enabled } + func stateFor(subfeatureID: BrowserServicesKit.SubfeatureID, parentFeatureID: BrowserServicesKit.ParentFeatureID, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { + return .enabled + } + + func cohorts(for subfeature: any BrowserServicesKit.PrivacySubfeature) -> [BrowserServicesKit.PrivacyConfigurationData.Cohort]? { + return nil + } + + func cohorts(subfeatureID: BrowserServicesKit.SubfeatureID, parentFeatureID: BrowserServicesKit.ParentFeatureID) -> [BrowserServicesKit.PrivacyConfigurationData.Cohort]? { + return nil + } + var identifier: String = "abcd" var version: String? = "123456789" var userUnprotectedDomains: [String] = []