diff --git a/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagLocalOverrides.swift b/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagLocalOverrides.swift new file mode 100644 index 000000000..62b556aa5 --- /dev/null +++ b/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagLocalOverrides.swift @@ -0,0 +1,200 @@ +// +// FeatureFlagLocalOverrides.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import Persistence + +/// This protocol defines persistence layer for feature flag overrides. +public protocol FeatureFlagLocalOverridesPersisting { + /// Return value for the flag override. + /// + /// If there's no override, this function should return `nil`. + /// + func value(for flag: Flag) -> Bool? + + /// Set new override for the feature flag. + /// + /// Flag can be overridden to `true` or `false`. Setting `nil` clears the override. + /// + func set(_ value: Bool?, for flag: Flag) +} + +public struct FeatureFlagLocalOverridesUserDefaultsPersistor: FeatureFlagLocalOverridesPersisting { + + public let keyValueStore: KeyValueStoring + + public init(keyValueStore: KeyValueStoring) { + self.keyValueStore = keyValueStore + } + + public func value(for flag: Flag) -> Bool? { + let key = key(for: flag) + return keyValueStore.object(forKey: key) as? Bool + } + + public func set(_ value: Bool?, for flag: Flag) { + let key = key(for: flag) + keyValueStore.set(value, forKey: key) + } + + /// This function returns the User Defaults key for a feature flag override. + /// + /// It uses camel case to simplify inter-process User Defaults KVO. + /// + private func key(for flag: Flag) -> String { + return "localOverride\(flag.rawValue.capitalizedFirstLetter)" + } +} + +private extension String { + var capitalizedFirstLetter: String { + return prefix(1).capitalized + dropFirst() + } +} + +/// This protocol defines the callback that can be used to reacting to feature flag changes. +public protocol FeatureFlagLocalOverridesHandling { + + /// This function is called whenever an effective value of a feature flag + /// changes as a result of adding or removing a local override. + /// + /// It can be implemented by client apps to react to changes to feature flag + /// value in runtime, caused by adjusting its local override. + func flagDidChange(_ featureFlag: Flag, isEnabled: Bool) +} + +/// `FeatureFlagLocalOverridesHandling` implementation providing Combine publisher for flag changes. +/// +/// It can be used by client apps if a more sophisticated handler isn't needed. +/// +public struct FeatureFlagOverridesPublishingHandler: FeatureFlagLocalOverridesHandling { + + public let flagDidChangePublisher: AnyPublisher<(F, Bool), Never> + private let flagDidChangeSubject = PassthroughSubject<(F, Bool), Never>() + + public init() { + flagDidChangePublisher = flagDidChangeSubject.eraseToAnyPublisher() + } + + public func flagDidChange(_ featureFlag: Flag, isEnabled: Bool) { + guard let flag = featureFlag as? F else { return } + flagDidChangeSubject.send((flag, isEnabled)) + } +} + +/// This protocol defines the interface for feature flag overriding mechanism. +/// +/// All flag overrides APIs only have effect if flag has `supportsLocalOverriding` set to `true`. +/// +public protocol FeatureFlagLocalOverriding: AnyObject { + + /// Handle to the feature flagger. + /// + /// It's used to query current, non-overriden state of a feature flag to + /// decide about calling `FeatureFlagLocalOverridesHandling.flagDidChange` + /// upon clearing an override. + var featureFlagger: FeatureFlagger? { get set } + + /// The action handler responding to feature flag changes. + var actionHandler: FeatureFlagLocalOverridesHandling { get } + + /// Returns the current override for a feature flag, or `nil` if override is not set. + func override(for featureFlag: Flag) -> Bool? + + /// Toggles override for a feature flag. + /// + /// If override is not currently present, it sets the override to the opposite of the current flag value. + /// + func toggleOverride(for featureFlag: Flag) + + /// Clears override for a feature flag. + /// + /// Calls `FeatureFlagLocalOverridesHandling.flagDidChange` if the effective flag value + /// changes as a result of clearing the override. + /// + func clearOverride(for featureFlag: Flag) + + /// Clears overrides for all feature flags. + /// + /// This function calls `clearOverride(for:)` for each flag. + /// + func clearAllOverrides(for flagType: Flag.Type) +} + +public final class FeatureFlagLocalOverrides: FeatureFlagLocalOverriding { + + public let actionHandler: FeatureFlagLocalOverridesHandling + public weak var featureFlagger: FeatureFlagger? + private let persistor: FeatureFlagLocalOverridesPersisting + + public convenience init( + keyValueStore: KeyValueStoring, + actionHandler: FeatureFlagLocalOverridesHandling + ) { + self.init( + persistor: FeatureFlagLocalOverridesUserDefaultsPersistor(keyValueStore: keyValueStore), + actionHandler: actionHandler + ) + } + + public init( + persistor: FeatureFlagLocalOverridesPersisting, + actionHandler: FeatureFlagLocalOverridesHandling + ) { + self.persistor = persistor + self.actionHandler = actionHandler + } + + public func override(for featureFlag: Flag) -> Bool? { + guard featureFlag.supportsLocalOverriding else { + return nil + } + return persistor.value(for: featureFlag) + } + + public func toggleOverride(for featureFlag: Flag) { + guard featureFlag.supportsLocalOverriding else { + return + } + let currentValue = persistor.value(for: featureFlag) ?? currentValue(for: featureFlag) ?? false + let newValue = !currentValue + persistor.set(newValue, for: featureFlag) + actionHandler.flagDidChange(featureFlag, isEnabled: newValue) + } + + public func clearOverride(for featureFlag: Flag) { + guard let override = override(for: featureFlag) else { + return + } + persistor.set(nil, for: featureFlag) + if let defaultValue = currentValue(for: featureFlag), defaultValue != override { + actionHandler.flagDidChange(featureFlag, isEnabled: defaultValue) + } + } + + public func clearAllOverrides(for flagType: Flag.Type) { + flagType.allCases.forEach { flag in + clearOverride(for: flag) + } + } + + private func currentValue(for featureFlag: Flag) -> Bool? { + featureFlagger?.isFeatureOn(for: featureFlag, allowOverride: true) + } +} diff --git a/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagger.swift b/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagger.swift index 42a3b99ef..d6f5fdee4 100644 --- a/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagger.swift +++ b/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagger.swift @@ -18,26 +18,147 @@ import Foundation -public protocol FeatureFlagger { - -/// Called from app features to determine whether a given feature is enabled. +/// This protocol defines a common interface for feature flags managed by FeatureFlagger. +/// +/// It should be implemented by the feature flag type in client apps. /// -/// `forProvider: F` takes a FeatureFlag type defined by the respective app which defines from what source it should be toggled -/// see `FeatureFlagSourceProviding` comments below for more details - func isFeatureOn(forProvider: F) -> Bool +public protocol FeatureFlagDescribing: CaseIterable { + + /// Returns a string representation of the flag, suitable for persisting the flag state to disk. + var rawValue: String { get } + + /// Return `true` here if a flag can be locally overridden. + /// + /// Local overriding mechanism requires passing `FeatureFlagOverriding` instance to + /// the `FeatureFlagger`. Then it will handle all feature flags that return `true` for + /// this property. + /// + /// > Note: Local feature flag overriding is gated by the internal user flag and has no effect + /// as long as internal user flag is off. + var supportsLocalOverriding: Bool { get } + + /// Defines the source of the 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 + /// case .credentialsAutofill: + /// return .remoteDevelopment(.subfeature(AutofillSubfeature.credentialsAutofill)) + /// case .duckPlayer: + /// return .remoteReleasable(.feature(.duckPlayer)) + /// } + /// } + /// ``` + var source: FeatureFlagSource { get } +} + +public enum FeatureFlagSource { + /// Completely disabled in all configurations + case disabled + + /// Enabled for internal users only. Cannot be toggled remotely + case internalOnly + + /// Toggled remotely using PrivacyConfiguration but only for internal users. Otherwise, disabled. + case remoteDevelopment(PrivacyConfigFeatureLevel) + + /// Toggled remotely using PrivacyConfiguration for all users + case remoteReleasable(PrivacyConfigFeatureLevel) +} + +public enum PrivacyConfigFeatureLevel { + /// Corresponds to a given top-level privacy config feature + case feature(PrivacyFeature) + + /// Corresponds to a given subfeature of a privacy config feature + case subfeature(any PrivacySubfeature) +} + +public protocol FeatureFlagger: AnyObject { + var internalUserDecider: InternalUserDecider { get } + + /// Local feature flag overriding mechanism. + /// + /// This property is optional and if kept as `nil`, local overrides + /// are not in use. Local overrides are only ever considered if a user + /// is internal user. + var localOverrides: FeatureFlagLocalOverriding? { get } + + /// Called from app features to determine whether a given feature is enabled. + /// + /// Feature Flag's `source` is checked to determine if the flag should be toggled. + /// If feature flagger provides overrides mechanism (`localOverrides` is not `nil`) + /// and the user is internal, local overrides is checked first and if present, + /// returned as flag value. + /// + /// > Note: Setting `allowOverride` to `false` skips checking local overrides. This can be used + /// when the non-overridden feature flag value is required. + /// + func isFeatureOn(for featureFlag: Flag, allowOverride: Bool) -> Bool +} + +public extension FeatureFlagger { + /// Called from app features to determine whether a given feature is enabled. + /// + /// Feature Flag's `source` is checked to determine if the flag should be toggled. + /// If feature flagger provides overrides mechanism (`localOverrides` is not `nil`) + /// and the user is internal, local overrides is checked first and if present, + /// returned as flag value. + /// + func isFeatureOn(for featureFlag: Flag) -> Bool { + isFeatureOn(for: featureFlag, allowOverride: true) + } } public class DefaultFeatureFlagger: FeatureFlagger { - private let internalUserDecider: InternalUserDecider - private let privacyConfigManager: PrivacyConfigurationManaging - public init(internalUserDecider: InternalUserDecider, privacyConfigManager: PrivacyConfigurationManaging) { + public let internalUserDecider: InternalUserDecider + public let privacyConfigManager: PrivacyConfigurationManaging + public let localOverrides: FeatureFlagLocalOverriding? + + public init( + internalUserDecider: InternalUserDecider, + privacyConfigManager: PrivacyConfigurationManaging + ) { self.internalUserDecider = internalUserDecider self.privacyConfigManager = privacyConfigManager + self.localOverrides = nil } - public func isFeatureOn(forProvider provider: F) -> Bool { - switch provider.source { + public init( + internalUserDecider: InternalUserDecider, + privacyConfigManager: PrivacyConfigurationManaging, + localOverrides: FeatureFlagLocalOverriding, + for: Flag.Type + ) { + self.internalUserDecider = internalUserDecider + self.privacyConfigManager = privacyConfigManager + self.localOverrides = localOverrides + localOverrides.featureFlagger = self + + // Clear all overrides if not an internal user + if !internalUserDecider.isInternalUser { + localOverrides.clearAllOverrides(for: Flag.self) + } + } + + public func isFeatureOn(for featureFlag: Flag, allowOverride: Bool) -> Bool { + if allowOverride, internalUserDecider.isInternalUser, let localOverride = localOverrides?.override(for: featureFlag) { + return localOverride + } + switch featureFlag.source { case .disabled: return false case .internalOnly: @@ -61,53 +182,3 @@ public class DefaultFeatureFlagger: FeatureFlagger { } } } - -/// To be implemented by the FeatureFlag enum type in the respective app. The source corresponds to -/// where the final value should come from. -/// -/// Example: -/// -/// ``` -/// public enum FeatureFlag: FeatureFlagSourceProviding { -/// case sync -/// case autofill -/// case cookieConsent -/// case duckPlayer -/// -/// var source: FeatureFlagSource { -/// case .sync: -/// return .disabled -/// case .cookieConsent: -/// return .internalOnly -/// case .credentialsAutofill: -/// return .remoteDevelopment(.subfeature(AutofillSubfeature.credentialsAutofill)) -/// case .duckPlayer: -/// return .remoteReleasable(.feature(.duckPlayer)) -/// } -/// } -/// ``` -public protocol FeatureFlagSourceProviding { - var source: FeatureFlagSource { get } -} - -public enum FeatureFlagSource { - /// Completely disabled in all configurations - case disabled - - /// Enabled for internal users only. Cannot be toggled remotely - case internalOnly - - /// Toggled remotely using PrivacyConfiguration but only for internal users. Otherwise, disabled. - case remoteDevelopment(PrivacyConfigFeatureLevel) - - /// Toggled remotely using PrivacyConfiguration for all users - case remoteReleasable(PrivacyConfigFeatureLevel) -} - -public enum PrivacyConfigFeatureLevel { - /// Corresponds to a given top-level privacy config feature - case feature(PrivacyFeature) - - /// Corresponds to a given subfeature of a privacy config feature - case subfeature(any PrivacySubfeature) -} diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift index a118bfb7f..3eb2db17e 100644 --- a/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift @@ -16,11 +16,43 @@ // limitations under the License. // -import XCTest import BrowserServicesKit +import TestUtils +import XCTest + +final class CapturingFeatureFlagOverriding: FeatureFlagLocalOverriding { + + var overrideCalls: [any FeatureFlagDescribing] = [] + var toggleOverideCalls: [any FeatureFlagDescribing] = [] + var clearOverrideCalls: [any FeatureFlagDescribing] = [] + var clearAllOverrideCallCount: Int = 0 + + var override: (any FeatureFlagDescribing) -> Bool? = { _ in nil } + + var actionHandler: any FeatureFlagLocalOverridesHandling = CapturingFeatureFlagLocalOverridesHandler() + weak var featureFlagger: FeatureFlagger? + + func override(for featureFlag: Flag) -> Bool? { + overrideCalls.append(featureFlag) + return override(featureFlag) + } + + func toggleOverride(for featureFlag: Flag) { + toggleOverideCalls.append(featureFlag) + } + + func clearOverride(for featureFlag: Flag) { + clearOverrideCalls.append(featureFlag) + } + + func clearAllOverrides(for flagType: Flag.Type) { + clearAllOverrideCallCount += 1 + } +} final class DefaultFeatureFlaggerTests: XCTestCase { var internalUserDeciderStore: MockInternalUserStoring! + var overrides: CapturingFeatureFlagOverriding! override func setUp() { super.setUp() @@ -34,22 +66,22 @@ final class DefaultFeatureFlaggerTests: XCTestCase { func testWhenDisabled_sourceDisabled_returnsFalse() { let featureFlagger = createFeatureFlagger() - XCTAssertFalse(featureFlagger.isFeatureOn(forProvider: FeatureFlagSource.disabled)) + XCTAssertFalse(featureFlagger.isFeatureOn(for: FeatureFlagSource.disabled)) } func testWhenInternalOnly_returnsIsInternalUserValue() { let featureFlagger = createFeatureFlagger() internalUserDeciderStore.isInternalUser = false - XCTAssertFalse(featureFlagger.isFeatureOn(forProvider: FeatureFlagSource.internalOnly)) + XCTAssertFalse(featureFlagger.isFeatureOn(for: FeatureFlagSource.internalOnly)) internalUserDeciderStore.isInternalUser = true - XCTAssertTrue(featureFlagger.isFeatureOn(forProvider: FeatureFlagSource.internalOnly)) + XCTAssertTrue(featureFlagger.isFeatureOn(for: FeatureFlagSource.internalOnly)) } func testWhenRemoteDevelopment_isNOTInternalUser_returnsFalse() { internalUserDeciderStore.isInternalUser = false let embeddedData = Self.embeddedConfig(autofillState: "enabled") let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) - XCTAssertFalse(featureFlagger.isFeatureOn(forProvider: FeatureFlagSource.remoteDevelopment(.feature(.autofill)))) + XCTAssertFalse(featureFlagger.isFeatureOn(for: FeatureFlagSource.remoteDevelopment(.feature(.autofill)))) } func testWhenRemoteDevelopment_isInternalUser_whenFeature_returnsPrivacyConfigValue() { @@ -109,6 +141,43 @@ final class DefaultFeatureFlaggerTests: XCTestCase { assertFeatureFlagger(with: embeddedData, willReturn: false, for: sourceProvider) } + // MARK: - Overrides + + func testWhenFeatureFlaggerIsInitializedWithLocalOverridesAndUserIsNotInternalThenAllFlagsAreCleared() throws { + internalUserDeciderStore.isInternalUser = false + _ = createFeatureFlaggerWithLocalOverrides() + XCTAssertEqual(overrides.clearAllOverrideCallCount, 1) + } + + func testWhenLocalOverridesIsSetUpAndUserIsInternalThenLocalOverrideTakesPrecedenceWhenCheckingFlagValue() throws { + let featureFlagger = createFeatureFlaggerWithLocalOverrides() + internalUserDeciderStore.isInternalUser = true + + overrides.override = { _ in return true } + + XCTAssertTrue(featureFlagger.isFeatureOn(for: TestFeatureFlag.overridableFlagDisabledByDefault)) + XCTAssertEqual(overrides.overrideCalls.count, 1) + XCTAssertEqual(try XCTUnwrap(overrides.overrideCalls.first as? TestFeatureFlag), .overridableFlagDisabledByDefault) + } + + func testWhenLocalOverridesIsSetUpAndUserIsInternalAndAllowOverrideIsFalseThenLocalOverrideIsNotCheckedWhenCheckingFlagValue() throws { + let featureFlagger = createFeatureFlaggerWithLocalOverrides() + internalUserDeciderStore.isInternalUser = true + + XCTAssertFalse(featureFlagger.isFeatureOn(for: TestFeatureFlag.overridableFlagDisabledByDefault, allowOverride: false)) + XCTAssertTrue(overrides.overrideCalls.isEmpty) + } + + func testWhenLocalOverridesIsSetUpAndUserIsNotInternalThenLocalOverrideIsNotCheckedWhenCheckingFlagValue() throws { + let featureFlagger = createFeatureFlaggerWithLocalOverrides() + internalUserDeciderStore.isInternalUser = false + + XCTAssertFalse(featureFlagger.isFeatureOn(for: TestFeatureFlag.overridableFlagDisabledByDefault)) + XCTAssertTrue(overrides.overrideCalls.isEmpty) + } + + // MARK: - Helpers + private func createFeatureFlagger(withMockedConfigData data: Data = DefaultFeatureFlaggerTests.embeddedConfig()) -> DefaultFeatureFlagger { let mockEmbeddedData = MockEmbeddedDataProvider(data: data, etag: "embeddedConfigETag") let manager = PrivacyConfigurationManager(fetchedETag: nil, @@ -120,6 +189,24 @@ final class DefaultFeatureFlaggerTests: XCTestCase { return DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfigManager: manager) } + private func createFeatureFlaggerWithLocalOverrides(withMockedConfigData data: Data = DefaultFeatureFlaggerTests.embeddedConfig()) -> DefaultFeatureFlagger { + let mockEmbeddedData = MockEmbeddedDataProvider(data: data, etag: "embeddedConfigETag") + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider()) + let internalUserDecider = DefaultInternalUserDecider(store: internalUserDeciderStore) + + overrides = CapturingFeatureFlagOverriding() + return DefaultFeatureFlagger( + internalUserDecider: internalUserDecider, + privacyConfigManager: manager, + localOverrides: overrides, + for: TestFeatureFlag.self + ) + } + private static func embeddedConfig(autofillState: String = "enabled", autofillSubfeatureForState: (subfeature: AutofillSubfeature, state: String) = (.credentialsAutofill, "enabled")) -> Data { """ @@ -142,14 +229,17 @@ final class DefaultFeatureFlaggerTests: XCTestCase { private func assertFeatureFlagger(with embeddedData: Data, willReturn bool: Bool, - for sourceProvider: FeatureFlagSourceProviding, + for sourceProvider: any FeatureFlagDescribing, file: StaticString = #file, line: UInt = #line) { let featureFlagger = createFeatureFlagger(withMockedConfigData: embeddedData) - XCTAssertEqual(featureFlagger.isFeatureOn(forProvider: sourceProvider), bool, file: file, line: line) + XCTAssertEqual(featureFlagger.isFeatureOn(for: sourceProvider), bool, file: file, line: line) } } -extension FeatureFlagSource: FeatureFlagSourceProviding { +extension FeatureFlagSource: FeatureFlagDescribing { + public static let allCases: [FeatureFlagSource] = [] + public var supportsLocalOverriding: Bool { false } + public var rawValue: String { "rawValue" } public var source: FeatureFlagSource { self } } diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift new file mode 100644 index 000000000..5e2407ca1 --- /dev/null +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift @@ -0,0 +1,167 @@ +// +// FeatureFlagLocalOverridesTests.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 BrowserServicesKit +import TestUtils +import XCTest + +final class CapturingFeatureFlagLocalOverridesHandler: FeatureFlagLocalOverridesHandling { + struct Parameters: Equatable { + let rawValue: String + let isEnabled: Bool + } + var calls: [Parameters] = [] + + func flagDidChange(_ featureFlag: Flag, isEnabled: Bool) { + calls.append(.init(rawValue: featureFlag.rawValue, isEnabled: isEnabled)) + } +} + +final class FeatureFlagLocalOverridesTests: XCTestCase { + var internalUserDeciderStore: MockInternalUserStoring! + var keyValueStore: MockKeyValueStore! + var actionHandler: CapturingFeatureFlagLocalOverridesHandler! + var overrides: FeatureFlagLocalOverrides! + var featureFlagger: FeatureFlagger! + + override func setUp() { + super.setUp() + internalUserDeciderStore = MockInternalUserStoring() + internalUserDeciderStore.isInternalUser = true + let internalUserDecider = DefaultInternalUserDecider(store: internalUserDeciderStore) + let privacyConfig = MockPrivacyConfiguration() + let privacyConfigManager = MockPrivacyConfigurationManager(privacyConfig: privacyConfig, internalUserDecider: internalUserDecider) + featureFlagger = DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfigManager: privacyConfigManager) + + keyValueStore = MockKeyValueStore() + actionHandler = CapturingFeatureFlagLocalOverridesHandler() + overrides = FeatureFlagLocalOverrides( + persistor: FeatureFlagLocalOverridesUserDefaultsPersistor(keyValueStore: keyValueStore), + actionHandler: actionHandler + ) + overrides.featureFlagger = featureFlagger + } + + func testThatOverridesAreNilByDefault() { + XCTAssertNil(overrides.override(for: TestFeatureFlag.nonOverridableFlag)) + XCTAssertNil(overrides.override(for: TestFeatureFlag.overridableFlagDisabledByDefault)) + XCTAssertNil(overrides.override(for: TestFeatureFlag.overridableFlagEnabledByDefault)) + } + + func testWhenFlagIsNotOverridableThenOverrideHasNoEffect() throws { + overrides.toggleOverride(for: TestFeatureFlag.nonOverridableFlag) + XCTAssertNil(overrides.override(for: TestFeatureFlag.nonOverridableFlag)) + } + + func testWhenFlagIsOverridableThenToggleOverrideChangesFlagValue() throws { + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagEnabledByDefault) + XCTAssertFalse(try XCTUnwrap(overrides.override(for: TestFeatureFlag.overridableFlagEnabledByDefault))) + + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + XCTAssertTrue(try XCTUnwrap(overrides.override(for: TestFeatureFlag.overridableFlagDisabledByDefault))) + } + + func testWhenToggleIsCalledMultipleTimesThenItAlternatesFlagValue() throws { + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + XCTAssertTrue(try XCTUnwrap(overrides.override(for: TestFeatureFlag.overridableFlagDisabledByDefault))) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + XCTAssertFalse(try XCTUnwrap(overrides.override(for: TestFeatureFlag.overridableFlagDisabledByDefault))) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + XCTAssertTrue(try XCTUnwrap(overrides.override(for: TestFeatureFlag.overridableFlagDisabledByDefault))) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + XCTAssertFalse(try XCTUnwrap(overrides.override(for: TestFeatureFlag.overridableFlagDisabledByDefault))) + } + + func testWhenFlagOverrideChangesThenActionHandlerIsCalled() throws { + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagEnabledByDefault) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagEnabledByDefault) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagEnabledByDefault) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagEnabledByDefault) + + XCTAssertEqual( + actionHandler.calls, + [ + .init(rawValue: TestFeatureFlag.overridableFlagDisabledByDefault.rawValue, isEnabled: true), + .init(rawValue: TestFeatureFlag.overridableFlagEnabledByDefault.rawValue, isEnabled: false), + .init(rawValue: TestFeatureFlag.overridableFlagDisabledByDefault.rawValue, isEnabled: false), + .init(rawValue: TestFeatureFlag.overridableFlagEnabledByDefault.rawValue, isEnabled: true), + .init(rawValue: TestFeatureFlag.overridableFlagEnabledByDefault.rawValue, isEnabled: false), + .init(rawValue: TestFeatureFlag.overridableFlagEnabledByDefault.rawValue, isEnabled: true) + ] + ) + } + + func testWhenClearOverrideIsCalledThenOverrideIsRemovedAndActionHandlerIsCalled() throws { + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + XCTAssertTrue(try XCTUnwrap(overrides.override(for: TestFeatureFlag.overridableFlagDisabledByDefault))) + overrides.clearOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + XCTAssertNil(overrides.override(for: TestFeatureFlag.overridableFlagDisabledByDefault)) + + XCTAssertEqual( + actionHandler.calls, + [ + .init(rawValue: TestFeatureFlag.overridableFlagDisabledByDefault.rawValue, isEnabled: true), + .init(rawValue: TestFeatureFlag.overridableFlagDisabledByDefault.rawValue, isEnabled: false) + ] + ) + } + + func testWhenOverrideIsEqualToNormalFlagValueAndClearOverrideIsCalledThenActionHandlerIsNotCalled() throws { + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + actionHandler.calls.removeAll() + overrides.clearOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + XCTAssertNil(overrides.override(for: TestFeatureFlag.nonOverridableFlag)) + XCTAssertTrue(actionHandler.calls.isEmpty) + } + + func testWhenClearOverrideIsCalledForNonOverridableFlagThenItHasNoEffect() throws { + XCTAssertNil(overrides.override(for: TestFeatureFlag.nonOverridableFlag)) + overrides.clearOverride(for: TestFeatureFlag.nonOverridableFlag) + XCTAssertNil(overrides.override(for: TestFeatureFlag.nonOverridableFlag)) + XCTAssertTrue(actionHandler.calls.isEmpty) + } + + func testWhenNoOverrideThenClearOverrideHasNoEffect() throws { + XCTAssertNil(overrides.override(for: TestFeatureFlag.overridableFlagDisabledByDefault)) + overrides.clearOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + overrides.clearOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + overrides.clearOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + XCTAssertNil(overrides.override(for: TestFeatureFlag.overridableFlagDisabledByDefault)) + + XCTAssertTrue(actionHandler.calls.isEmpty) + } + + func testClearAllOverrides() throws { + overrides.toggleOverride(for: TestFeatureFlag.nonOverridableFlag) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagDisabledByDefault) + overrides.toggleOverride(for: TestFeatureFlag.overridableFlagEnabledByDefault) + actionHandler.calls.removeAll() + + overrides.clearAllOverrides(for: TestFeatureFlag.self) + XCTAssertEqual( + actionHandler.calls, + [ + .init(rawValue: TestFeatureFlag.overridableFlagDisabledByDefault.rawValue, isEnabled: false), + .init(rawValue: TestFeatureFlag.overridableFlagEnabledByDefault.rawValue, isEnabled: true) + ] + ) + } +} diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/TestFeatureFlag.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/TestFeatureFlag.swift new file mode 100644 index 000000000..8c1a43ee7 --- /dev/null +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/TestFeatureFlag.swift @@ -0,0 +1,45 @@ +// +// TestFeatureFlag.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 BrowserServicesKit + +enum TestFeatureFlag: String, FeatureFlagDescribing { + case nonOverridableFlag + case overridableFlagDisabledByDefault + case overridableFlagEnabledByDefault + + var supportsLocalOverriding: Bool { + switch self { + case .nonOverridableFlag: + return false + case .overridableFlagDisabledByDefault, .overridableFlagEnabledByDefault: + return true + } + } + + var source: FeatureFlagSource { + switch self { + case .nonOverridableFlag: + return .internalOnly + case .overridableFlagDisabledByDefault: + return .disabled + case .overridableFlagEnabledByDefault: + return .internalOnly + } + } +}