diff --git a/Package.swift b/Package.swift index 747570775..9ca8e565d 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let package = Package( .library(name: "Common", targets: ["Common"]), .library(name: "TestUtils", targets: ["TestUtils"]), .library(name: "DDGSync", targets: ["DDGSync"]), + .library(name: "BrowserServicesKitTestsUtils", targets: ["BrowserServicesKitTestsUtils"]), .library(name: "Persistence", targets: ["Persistence"]), .library(name: "Bookmarks", targets: ["Bookmarks"]), .library(name: "BloomFilterWrapper", targets: ["BloomFilterWrapper"]), @@ -26,6 +27,7 @@ let package = Package( .library(name: "Configuration", targets: ["Configuration"]), .library(name: "Networking", targets: ["Networking"]), .library(name: "RemoteMessaging", targets: ["RemoteMessaging"]), + .library(name: "RemoteMessagingTestsUtils", targets: ["RemoteMessagingTestsUtils"]), .library(name: "Navigation", targets: ["Navigation"]), .library(name: "SyncDataProviders", targets: ["SyncDataProviders"]), .library(name: "NetworkProtection", targets: ["NetworkProtection"]), @@ -76,6 +78,12 @@ let package = Package( .define("DEBUG", .when(configuration: .debug)) ] ), + .target( + name: "BrowserServicesKitTestsUtils", + dependencies: [ + "BrowserServicesKit", + ] + ), .target( name: "Persistence", dependencies: [ @@ -261,9 +269,11 @@ let package = Package( name: "RemoteMessaging", dependencies: [ "Common", + "Configuration", "BrowserServicesKit", "Networking", "Persistence", + "Subscription" ], resources: [ .process("CoreData/RemoteMessaging.xcdatamodeld") @@ -272,6 +282,12 @@ let package = Package( .define("DEBUG", .when(configuration: .debug)) ] ), + .target( + name: "RemoteMessagingTestsUtils", + dependencies: [ + "RemoteMessaging", + ] + ), .target( name: "SyncDataProviders", dependencies: [ @@ -400,7 +416,7 @@ let package = Package( name: "BrowserServicesKitTests", dependencies: [ "BrowserServicesKit", - "RemoteMessaging", // Move tests later (lots of test dependencies in BSK) + "BrowserServicesKitTestsUtils", "SecureStorageTestsUtils", "TestUtils", "Subscription" @@ -482,10 +498,16 @@ let package = Package( .testTarget( name: "RemoteMessagingTests", dependencies: [ + "BrowserServicesKitTestsUtils", "RemoteMessaging", + "RemoteMessagingTestsUtils", + "TestUtils", ], resources: [ .copy("Resources/remote-messaging-config-example.json"), + .copy("Resources/remote-messaging-config-malformed.json"), + .copy("Resources/remote-messaging-config-unsupported-items.json"), + .copy("Resources/remote-messaging-config.json"), ] ), .testTarget( diff --git a/Sources/Bookmarks/BookmarkUtils.swift b/Sources/Bookmarks/BookmarkUtils.swift index 03955e2f2..9b3a63410 100644 --- a/Sources/Bookmarks/BookmarkUtils.swift +++ b/Sources/Bookmarks/BookmarkUtils.swift @@ -184,6 +184,31 @@ public struct BookmarkUtils { return result.compactMap { $0[#keyPath(BookmarkEntity.title)] as? String } } + public static func numberOfBookmarks(in context: NSManagedObjectContext) -> Int { + let request = BookmarkEntity.fetchRequest() + request.predicate = NSPredicate( + format: "%K == false AND %K == false AND (%K == NO OR %K == nil)", + #keyPath(BookmarkEntity.isFolder), + #keyPath(BookmarkEntity.isPendingDeletion), + #keyPath(BookmarkEntity.isStub), #keyPath(BookmarkEntity.isStub)) + return (try? context.count(for: request)) ?? 0 + } + + public static func numberOfFavorites(for displayMode: FavoritesDisplayMode, in context: NSManagedObjectContext) -> Int { + guard let displayedFavoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: displayMode.displayedFolder.rawValue, in: context) else { + return 0 + } + + let request = BookmarkEntity.fetchRequest() + request.predicate = NSPredicate(format: "%K CONTAINS %@ AND %K == false AND %K == false AND (%K == NO OR %K == nil)", + #keyPath(BookmarkEntity.favoriteFolders), + displayedFavoritesFolder, + #keyPath(BookmarkEntity.isFolder), + #keyPath(BookmarkEntity.isPendingDeletion), + #keyPath(BookmarkEntity.isStub), #keyPath(BookmarkEntity.isStub)) + return (try? context.count(for: request)) ?? 0 + } + // MARK: Internal @discardableResult diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index 34445414d..a1f3a8514 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -48,6 +48,7 @@ public enum PrivacyFeature: String { case sslCertificates case brokenSiteReportExperiment case toggleReports + case remoteMessaging } /// An abstraction to be implemented by any "subfeature" of a given `PrivacyConfiguration` feature. diff --git a/Sources/BrowserServicesKitTestsUtils/MockEmailManagerRequestDelegate.swift b/Sources/BrowserServicesKitTestsUtils/MockEmailManagerRequestDelegate.swift new file mode 100644 index 000000000..7b3a098f5 --- /dev/null +++ b/Sources/BrowserServicesKitTestsUtils/MockEmailManagerRequestDelegate.swift @@ -0,0 +1,65 @@ +// +// MockEmailManagerRequestDelegate.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. +// + +@testable import BrowserServicesKit +import Foundation + +public class MockEmailManagerRequestDelegate: EmailManagerRequestDelegate { + + public init(didSendMockAliasRequest: @escaping () -> Void = {}) { + self.didSendMockAliasRequest = didSendMockAliasRequest + } + + public var activeTask: URLSessionTask? + public var mockAliases: [String] = [] + public var waitlistTimestamp: Int = 1 + public var didSendMockAliasRequest: () -> Void + + // swiftlint:disable function_parameter_count + public func emailManager(_ emailManager: EmailManager, requested url: URL, method: String, headers: [String: String], parameters: [String: String]?, httpBody: Data?, timeoutInterval: TimeInterval) async throws -> Data { + switch url.absoluteString { + case EmailUrls.Url.emailAlias: return try processMockAliasRequest().get() + default: fatalError("\(#file): Unsupported URL passed to mock request delegate: \(url)") + } + } + // swiftlint:enable function_parameter_count + + public var keychainAccessErrorAccessType: EmailKeychainAccessType? + public var keychainAccessError: EmailKeychainAccessError? + + public func emailManagerKeychainAccessFailed(_ emailManager: EmailManager, + accessType: EmailKeychainAccessType, + error: EmailKeychainAccessError) { + keychainAccessErrorAccessType = accessType + keychainAccessError = error + } + + private func processMockAliasRequest() -> Result { + didSendMockAliasRequest() + + if mockAliases.first != nil { + let alias = mockAliases.removeFirst() + let jsonString = "{\"address\":\"\(alias)\"}" + let data = jsonString.data(using: .utf8)! + return .success(data) + } else { + return .failure(AliasRequestError.noDataError) + } + } + +} diff --git a/Sources/BrowserServicesKitTestsUtils/MockEmailManagerStorage.swift b/Sources/BrowserServicesKitTestsUtils/MockEmailManagerStorage.swift new file mode 100644 index 000000000..38affe73e --- /dev/null +++ b/Sources/BrowserServicesKitTestsUtils/MockEmailManagerStorage.swift @@ -0,0 +1,90 @@ +// +// MockEmailManagerStorage.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 Foundation + +public class MockEmailManagerStorage: EmailManagerStorage { + + public var mockError: EmailKeychainAccessError? + + public var mockUsername: String? + public var mockToken: String? + public var mockAlias: String? + public var mockCohort: String? + public var mockLastUseDate: String? + + public var storeTokenCallback: ((String, String, String?) -> Void)? + public var storeAliasCallback: ((String) -> Void)? + public var storeLastUseDateCallback: ((String) -> Void)? + public var deleteAliasCallback: (() -> Void)? + public var deleteAuthenticationStateCallback: (() -> Void)? + public var deleteWaitlistStateCallback: (() -> Void)? + + public init() {} + + public func getUsername() throws -> String? { + if let mockError = mockError { throw mockError } + return mockUsername + } + + public func getToken() throws -> String? { + if let mockError = mockError { throw mockError } + return mockToken + } + + public func getAlias() throws -> String? { + if let mockError = mockError { throw mockError } + return mockAlias + } + + public func getCohort() throws -> String? { + if let mockError = mockError { throw mockError } + return mockCohort + } + + public func getLastUseDate() throws -> String? { + if let mockError = mockError { throw mockError } + return mockLastUseDate + } + + public func store(token: String, username: String, cohort: String?) throws { + storeTokenCallback?(token, username, cohort) + } + + public func store(alias: String) throws { + storeAliasCallback?(alias) + } + + public func store(lastUseDate: String) throws { + storeLastUseDateCallback?(lastUseDate) + } + + public func deleteAlias() { + deleteAliasCallback?() + } + + public func deleteAuthenticationState() { + deleteAuthenticationStateCallback?() + } + + public func deleteWaitlistState() { + deleteWaitlistStateCallback?() + } + +} diff --git a/Sources/BrowserServicesKitTestsUtils/MockStatisticsStore.swift b/Sources/BrowserServicesKitTestsUtils/MockStatisticsStore.swift new file mode 100644 index 000000000..90c108dad --- /dev/null +++ b/Sources/BrowserServicesKitTestsUtils/MockStatisticsStore.swift @@ -0,0 +1,36 @@ +// +// MockStatisticsStore.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 Foundation + +public class MockStatisticsStore: StatisticsStore { + + public init() {} + + public var installDate: Date? + public var atb: String? + public var searchRetentionAtb: String? + public var appRetentionAtb: String? + + public var hasInstallStatistics: Bool { + return atb != nil + } + + public var variant: String? +} diff --git a/Sources/BrowserServicesKitTestsUtils/MockVariant.swift b/Sources/BrowserServicesKitTestsUtils/MockVariant.swift new file mode 100644 index 000000000..a190de953 --- /dev/null +++ b/Sources/BrowserServicesKitTestsUtils/MockVariant.swift @@ -0,0 +1,34 @@ +// +// MockVariant.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 Foundation + +public class MockVariant: Variant { + public var name: String + public var weight: Int + public var isIncluded: () -> Bool + public var features: [FeatureName] + + public init(name: String, weight: Int, isIncluded: @escaping () -> Bool, features: [FeatureName]) { + self.name = name + self.weight = weight + self.isIncluded = isIncluded + self.features = features + } +} diff --git a/Tests/RemoteMessagingTests/Mocks/Mocks.swift b/Sources/BrowserServicesKitTestsUtils/MockVariantManager.swift similarity index 61% rename from Tests/RemoteMessagingTests/Mocks/Mocks.swift rename to Sources/BrowserServicesKitTestsUtils/MockVariantManager.swift index 4e6d33a60..bde0d97fa 100644 --- a/Tests/RemoteMessagingTests/Mocks/Mocks.swift +++ b/Sources/BrowserServicesKitTestsUtils/MockVariantManager.swift @@ -1,5 +1,5 @@ // -// Mocks.swift +// MockVariantManager.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -16,46 +16,32 @@ // limitations under the License. // -import Foundation import BrowserServicesKit +import Foundation -class MockStatisticsStore: StatisticsStore { - - var installDate: Date? - var atb: String? - var searchRetentionAtb: String? - var appRetentionAtb: String? - - var hasInstallStatistics: Bool { - return atb != nil - } - - var variant: String? -} - -struct MockVariantManager: VariantManager { +public class MockVariantManager: VariantManager { - var isSupportedReturns = false { + public var isSupportedReturns = false { didSet { let newValue = isSupportedReturns isSupportedBlock = { _ in return newValue } } } - var isSupportedBlock: (FeatureName) -> Bool + public var isSupportedBlock: (FeatureName) -> Bool - var currentVariant: Variant? + public var currentVariant: Variant? - init(isSupportedReturns: Bool = false, currentVariant: Variant? = nil) { + public init(isSupportedReturns: Bool = false, currentVariant: Variant? = nil) { self.isSupportedReturns = isSupportedReturns self.isSupportedBlock = { _ in return isSupportedReturns } self.currentVariant = currentVariant } - func assignVariantIfNeeded(_ newInstallCompletion: (VariantManager) -> Void) { + public func assignVariantIfNeeded(_ newInstallCompletion: (VariantManager) -> Void) { } - func isSupported(feature: FeatureName) -> Bool { + public func isSupported(feature: FeatureName) -> Bool { return isSupportedBlock(feature) } diff --git a/Sources/Configuration/Configuration.swift b/Sources/Configuration/Configuration.swift index b37f55f80..67d89d538 100644 --- a/Sources/Configuration/Configuration.swift +++ b/Sources/Configuration/Configuration.swift @@ -33,6 +33,7 @@ public enum Configuration: String, CaseIterable, Sendable { case surrogates case trackerDataSet case FBConfig + case remoteMessagingConfig private static var urlProvider: ConfigurationURLProviding? public static func setURLProvider(_ urlProvider: ConfigurationURLProviding) { diff --git a/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift b/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift new file mode 100644 index 000000000..2b53ad288 --- /dev/null +++ b/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift @@ -0,0 +1,167 @@ +// +// DefaultRemoteMessagingSurveyURLBuilder.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 Common +import Foundation +import Subscription + +public protocol VPNActivationDateProviding { + func daysSinceActivation() -> Int? + func daysSinceLastActive() -> Int? +} + +public struct DefaultRemoteMessagingSurveyURLBuilder: RemoteMessagingSurveyActionMapping { + + private let statisticsStore: StatisticsStore + private let vpnActivationDateStore: VPNActivationDateProviding + private let subscription: Subscription? + + public init(statisticsStore: StatisticsStore, vpnActivationDateStore: VPNActivationDateProviding, subscription: Subscription?) { + self.statisticsStore = statisticsStore + self.vpnActivationDateStore = vpnActivationDateStore + self.subscription = subscription + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + public func add(parameters: [RemoteMessagingSurveyActionParameter], to surveyURL: URL) -> URL { + guard var components = URLComponents(string: surveyURL.absoluteString) else { + assertionFailure("Could not build URL components from survey URL") + return surveyURL + } + + var queryItems = components.queryItems ?? [] + + for parameter in parameters { + switch parameter { + case .atb: + if let atb = statisticsStore.atb { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: atb)) + } + case .atbVariant: + if let variant = statisticsStore.variant { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: variant)) + } + case .osVersion: + queryItems.append(URLQueryItem(name: parameter.rawValue, value: AppVersion.shared.osVersion)) + case .appVersion: + queryItems.append(URLQueryItem(name: parameter.rawValue, value: AppVersion.shared.versionAndBuildNumber)) + case .hardwareModel: + let model = hardwareModel().addingPercentEncoding(withAllowedCharacters: .alphanumerics) + queryItems.append(URLQueryItem(name: parameter.rawValue, value: model)) + case .daysInstalled: + if let installDate = statisticsStore.installDate, + let daysSinceInstall = Calendar.current.numberOfDaysBetween(installDate, and: Date()) { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: String(describing: daysSinceInstall))) + } + case .privacyProStatus: + if let privacyProStatusSurveyParameter = subscription?.privacyProStatusSurveyParameter { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: privacyProStatusSurveyParameter)) + } + case .privacyProPlatform: + if let privacyProPlatformSurveyParameter = subscription?.privacyProPlatformSurveyParameter { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: privacyProPlatformSurveyParameter)) + } + case .privacyProBilling: + if let privacyProBillingSurveyParameter = subscription?.privacyProBillingSurveyParameter { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: privacyProBillingSurveyParameter)) + } + + case .privacyProDaysSincePurchase: + if let startDate = subscription?.startedAt, + let daysSincePurchase = Calendar.current.numberOfDaysBetween(startDate, and: Date()) { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: String(describing: daysSincePurchase))) + } + case .privacyProDaysUntilExpiry: + if let expiryDate = subscription?.expiresOrRenewsAt, + let daysUntilExpiry = Calendar.current.numberOfDaysBetween(Date(), and: expiryDate) { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: String(describing: daysUntilExpiry))) + } + case .vpnFirstUsed: + if let vpnFirstUsed = vpnActivationDateStore.daysSinceActivation() { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: String(describing: vpnFirstUsed))) + } + case .vpnLastUsed: + if let vpnLastUsed = vpnActivationDateStore.daysSinceLastActive() { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: String(describing: vpnLastUsed))) + } + } + } + + components.queryItems = queryItems + + return components.url ?? surveyURL + } + + private func hardwareModel() -> String { + var systemInfo = utsname() + uname(&systemInfo) + + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + + return identifier + } + +} + +extension Subscription { + var privacyProStatusSurveyParameter: String { + switch status { + case .autoRenewable: + return "auto_renewable" + case .notAutoRenewable: + return "not_auto_renewable" + case .gracePeriod: + return "grace_period" + case .inactive: + return "inactive" + case .expired: + return "expired" + case .unknown: + return "unknown" + } + } + + var privacyProPlatformSurveyParameter: String { + switch platform { + case .apple: + return "apple" + case .google: + return "google" + case .stripe: + return "stripe" + case .unknown: + return "unknown" + } + } + + var privacyProBillingSurveyParameter: String { + switch billingPeriod { + case .monthly: + return "monthly" + case .yearly: + return "yearly" + case .unknown: + return "unknown" + } + } +} diff --git a/Sources/RemoteMessaging/Matchers/AppAttributeMatcher.swift b/Sources/RemoteMessaging/Matchers/AppAttributeMatcher.swift index 53e877cbe..5ad348fce 100644 --- a/Sources/RemoteMessaging/Matchers/AppAttributeMatcher.swift +++ b/Sources/RemoteMessaging/Matchers/AppAttributeMatcher.swift @@ -20,7 +20,7 @@ import Foundation import Common import BrowserServicesKit -public struct AppAttributeMatcher: AttributeMatcher { +public struct AppAttributeMatcher: AttributeMatching { private let bundleId: String private let appVersion: String @@ -48,7 +48,7 @@ public struct AppAttributeMatcher: AttributeMatcher { } // swiftlint:disable cyclomatic_complexity - func evaluate(matchingAttribute: MatchingAttribute) -> EvaluationResult? { + public func evaluate(matchingAttribute: MatchingAttribute) -> EvaluationResult? { switch matchingAttribute { case let matchingAttribute as IsInternalUserMatchingAttribute: guard let value = matchingAttribute.value else { diff --git a/Sources/RemoteMessaging/Matchers/AttributeMatcher.swift b/Sources/RemoteMessaging/Matchers/AttributeMatching.swift similarity index 91% rename from Sources/RemoteMessaging/Matchers/AttributeMatcher.swift rename to Sources/RemoteMessaging/Matchers/AttributeMatching.swift index e7ba2d3f6..6a1e6df77 100644 --- a/Sources/RemoteMessaging/Matchers/AttributeMatcher.swift +++ b/Sources/RemoteMessaging/Matchers/AttributeMatching.swift @@ -1,5 +1,5 @@ // -// AttributeMatcher.swift +// AttributeMatching.swift // // Copyright © 2022 DuckDuckGo. All rights reserved. // @@ -18,6 +18,6 @@ import Foundation -protocol AttributeMatcher { +public protocol AttributeMatching { func evaluate(matchingAttribute: MatchingAttribute) -> EvaluationResult? } diff --git a/Sources/RemoteMessaging/Matchers/DeviceAttributeMatcher.swift b/Sources/RemoteMessaging/Matchers/DeviceAttributeMatcher.swift index 2dfa74b7b..252182f0b 100644 --- a/Sources/RemoteMessaging/Matchers/DeviceAttributeMatcher.swift +++ b/Sources/RemoteMessaging/Matchers/DeviceAttributeMatcher.swift @@ -19,7 +19,7 @@ import Foundation import Common -public struct DeviceAttributeMatcher: AttributeMatcher { +public struct DeviceAttributeMatcher: AttributeMatching { let osVersion: String let localeIdentifier: String @@ -33,7 +33,7 @@ public struct DeviceAttributeMatcher: AttributeMatcher { self.localeIdentifier = locale } - func evaluate(matchingAttribute: MatchingAttribute) -> EvaluationResult? { + public func evaluate(matchingAttribute: MatchingAttribute) -> EvaluationResult? { switch matchingAttribute { case let matchingAttribute as LocaleMatchingAttribute: return StringArrayMatchingAttribute(matchingAttribute.value).matches(value: LocaleMatchingAttribute.localeIdentifierAsJsonFormat(localeIdentifier)) diff --git a/Sources/RemoteMessaging/Matchers/EvaluationResult.swift b/Sources/RemoteMessaging/Matchers/EvaluationResult.swift index fa17c2d71..ef27be532 100644 --- a/Sources/RemoteMessaging/Matchers/EvaluationResult.swift +++ b/Sources/RemoteMessaging/Matchers/EvaluationResult.swift @@ -18,7 +18,7 @@ import Foundation -enum EvaluationResult { +public enum EvaluationResult { case match case fail case nextMessage diff --git a/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift b/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift index 97e669c89..33fc9952d 100644 --- a/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift +++ b/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift @@ -20,7 +20,82 @@ import Foundation import Common import BrowserServicesKit -public struct UserAttributeMatcher: AttributeMatcher { +#if os(iOS) +public typealias UserAttributeMatcher = MobileUserAttributeMatcher +#elseif os(macOS) +public typealias UserAttributeMatcher = DesktopUserAttributeMatcher +#endif + +public typealias DesktopUserAttributeMatcher = CommonUserAttributeMatcher + +public struct MobileUserAttributeMatcher: AttributeMatching { + + private enum PrivacyProSubscriptionStatus: String { + case active + case expiring + case expired + } + + private let isWidgetInstalled: Bool + + private let commonUserAttributeMatcher: CommonUserAttributeMatcher + + public init(statisticsStore: StatisticsStore, + variantManager: VariantManager, + emailManager: EmailManager = EmailManager(), + bookmarksCount: Int, + favoritesCount: Int, + appTheme: String, + isWidgetInstalled: Bool, + daysSinceNetPEnabled: Int, + isPrivacyProEligibleUser: Bool, + isPrivacyProSubscriber: Bool, + privacyProDaysSinceSubscribed: Int, + privacyProDaysUntilExpiry: Int, + privacyProPurchasePlatform: String?, + isPrivacyProSubscriptionActive: Bool, + isPrivacyProSubscriptionExpiring: Bool, + isPrivacyProSubscriptionExpired: Bool, + dismissedMessageIds: [String] + ) { + self.isWidgetInstalled = isWidgetInstalled + + commonUserAttributeMatcher = .init( + statisticsStore: statisticsStore, + variantManager: variantManager, + emailManager: emailManager, + bookmarksCount: bookmarksCount, + favoritesCount: favoritesCount, + appTheme: appTheme, + daysSinceNetPEnabled: daysSinceNetPEnabled, + isPrivacyProEligibleUser: isPrivacyProEligibleUser, + isPrivacyProSubscriber: isPrivacyProSubscriber, + privacyProDaysSinceSubscribed: privacyProDaysSinceSubscribed, + privacyProDaysUntilExpiry: privacyProDaysUntilExpiry, + privacyProPurchasePlatform: privacyProPurchasePlatform, + isPrivacyProSubscriptionActive: isPrivacyProSubscriptionActive, + isPrivacyProSubscriptionExpiring: isPrivacyProSubscriptionExpiring, + isPrivacyProSubscriptionExpired: isPrivacyProSubscriptionExpired, + dismissedMessageIds: dismissedMessageIds + ) + } + + public func evaluate(matchingAttribute: MatchingAttribute) -> EvaluationResult? { + switch matchingAttribute { + case let matchingAttribute as WidgetAddedMatchingAttribute: + guard let value = matchingAttribute.value else { + return .fail + } + + return BooleanMatchingAttribute(value).matches(value: isWidgetInstalled) + default: + return commonUserAttributeMatcher.evaluate(matchingAttribute: matchingAttribute) + } + } + +} + +public struct CommonUserAttributeMatcher: AttributeMatching { private enum PrivacyProSubscriptionStatus: String { case active @@ -34,7 +109,6 @@ public struct UserAttributeMatcher: AttributeMatcher { private let appTheme: String private let bookmarksCount: Int private let favoritesCount: Int - private let isWidgetInstalled: Bool private let daysSinceNetPEnabled: Int private let isPrivacyProEligibleUser: Bool private let isPrivacyProSubscriber: Bool @@ -52,7 +126,6 @@ public struct UserAttributeMatcher: AttributeMatcher { bookmarksCount: Int, favoritesCount: Int, appTheme: String, - isWidgetInstalled: Bool, daysSinceNetPEnabled: Int, isPrivacyProEligibleUser: Bool, isPrivacyProSubscriber: Bool, @@ -70,7 +143,6 @@ public struct UserAttributeMatcher: AttributeMatcher { self.appTheme = appTheme self.bookmarksCount = bookmarksCount self.favoritesCount = favoritesCount - self.isWidgetInstalled = isWidgetInstalled self.daysSinceNetPEnabled = daysSinceNetPEnabled self.isPrivacyProEligibleUser = isPrivacyProEligibleUser self.isPrivacyProSubscriber = isPrivacyProSubscriber @@ -84,7 +156,7 @@ public struct UserAttributeMatcher: AttributeMatcher { } // swiftlint:disable:next cyclomatic_complexity function_body_length - func evaluate(matchingAttribute: MatchingAttribute) -> EvaluationResult? { + public func evaluate(matchingAttribute: MatchingAttribute) -> EvaluationResult? { switch matchingAttribute { case let matchingAttribute as AppThemeMatchingAttribute: guard let value = matchingAttribute.value else { @@ -121,12 +193,6 @@ public struct UserAttributeMatcher: AttributeMatcher { } else { return RangeIntMatchingAttribute(min: matchingAttribute.min, max: matchingAttribute.max).matches(value: favoritesCount) } - case let matchingAttribute as WidgetAddedMatchingAttribute: - guard let value = matchingAttribute.value else { - return .fail - } - - return BooleanMatchingAttribute(value).matches(value: isWidgetInstalled) case let matchingAttribute as DaysSinceNetPEnabledMatchingAttribute: if matchingAttribute.value != MatchingAttributeDefaults.intDefaultValue { return IntMatchingAttribute(matchingAttribute.value).matches(value: daysSinceNetPEnabled) diff --git a/Sources/RemoteMessaging/Model/MatchingAttributes.swift b/Sources/RemoteMessaging/Model/MatchingAttributes.swift index 388d15995..f31ec79a9 100644 --- a/Sources/RemoteMessaging/Model/MatchingAttributes.swift +++ b/Sources/RemoteMessaging/Model/MatchingAttributes.swift @@ -27,7 +27,7 @@ private enum RuleAttributes { static let since = "since" } -protocol MatchingAttribute { +public protocol MatchingAttribute { var fallback: Bool? { get } } diff --git a/Sources/RemoteMessaging/RemoteMessageRequest.swift b/Sources/RemoteMessaging/RemoteMessageRequest.swift deleted file mode 100644 index 37dd95ef5..000000000 --- a/Sources/RemoteMessaging/RemoteMessageRequest.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// RemoteMessageRequest.swift -// -// Copyright © 2022 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import Networking - -public struct RemoteMessageRequest { - - public let endpoint: URL - - public init(endpoint: URL) { - self.endpoint = endpoint - } - - public func getRemoteMessage(completionHandler: @escaping (Result) -> Void) { - let configuration = APIRequest.Configuration(url: endpoint) - let request = APIRequest(configuration: configuration, urlSession: .session()) - - request.fetch { response, error in - guard let data = response?.data, error == nil else { - completionHandler(.failure(.noData)) - return - } - - do { - let decoder = JSONDecoder() - let response = try decoder.decode(RemoteMessageResponse.JsonRemoteMessagingConfig.self, from: data) - - completionHandler(.success(response)) - } catch { - completionHandler(.failure(.parsingFailed)) - } - } - } -} diff --git a/Sources/RemoteMessaging/RemoteMessagingAvailabilityProviding.swift b/Sources/RemoteMessaging/RemoteMessagingAvailabilityProviding.swift new file mode 100644 index 000000000..18dc79d57 --- /dev/null +++ b/Sources/RemoteMessaging/RemoteMessagingAvailabilityProviding.swift @@ -0,0 +1,59 @@ +// +// RemoteMessagingAvailabilityProviding.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 Combine +import Foundation + +/** + * This protocol provides abstraction for the RMF feature flag. + */ +public protocol RemoteMessagingAvailabilityProviding { + var isRemoteMessagingAvailable: Bool { get } + + var isRemoteMessagingAvailablePublisher: AnyPublisher { get } +} + +/** + * This struct exposes RMF feature flag from Privacy Config. + * + * We're using a struct like this because PrivacyConfigurationManaging (a protocol from another Swift module) + * can't be extended with conformance to a protocol from this Swift module. + */ +public struct PrivacyConfigurationRemoteMessagingAvailabilityProvider: RemoteMessagingAvailabilityProviding { + public init(privacyConfigurationManager: PrivacyConfigurationManaging) { + self.privacyConfigurationManager = privacyConfigurationManager + } + + public var isRemoteMessagingAvailable: Bool { + privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .remoteMessaging) + } + + /** + * This publisher is guaranteed to emit values without duplicates. Events are emitted on an arbitrary thread. + */ + public var isRemoteMessagingAvailablePublisher: AnyPublisher { + privacyConfigurationManager.updatesPublisher + .dropFirst() // skip initial event emitted from PrivacyConfigurationManager initializer's `reload` + .map { _ in isRemoteMessagingAvailable } + .removeDuplicates() + .eraseToAnyPublisher() + } + + private var privacyConfigurationManager: PrivacyConfigurationManaging +} diff --git a/Sources/RemoteMessaging/RemoteMessagingConfigFetcher.swift b/Sources/RemoteMessaging/RemoteMessagingConfigFetcher.swift new file mode 100644 index 000000000..b1bb1aeeb --- /dev/null +++ b/Sources/RemoteMessaging/RemoteMessagingConfigFetcher.swift @@ -0,0 +1,62 @@ +// +// RemoteMessagingConfigFetcher.swift +// +// Copyright © 2022 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 Configuration +import Foundation +import Networking + +/** + * This protocol defines API for fetching RMF config from the server + */ +public protocol RemoteMessagingConfigFetching { + func fetchRemoteMessagingConfig() async throws -> RemoteMessageResponse.JsonRemoteMessagingConfig +} + +public struct RemoteMessagingConfigFetcher: RemoteMessagingConfigFetching { + + public let configurationFetcher: ConfigurationFetcher + public let configurationStore: ConfigurationStoring + + public init(configurationFetcher: ConfigurationFetcher, configurationStore: ConfigurationStoring) { + self.configurationFetcher = configurationFetcher + self.configurationStore = configurationStore + } + + public func fetchRemoteMessagingConfig() async throws -> RemoteMessageResponse.JsonRemoteMessagingConfig { + let isDebug: Bool = { +#if DEBUG + true +#else + false +#endif + }() + do { + try await configurationFetcher.fetch(.remoteMessagingConfig, isDebug: isDebug) + } catch APIRequest.Error.invalidStatusCode(304) {} + + guard let responseData = configurationStore.loadData(for: .remoteMessagingConfig) else { + throw RemoteMessageResponse.StatusError.noData + } + + do { + return try JSONDecoder().decode(RemoteMessageResponse.JsonRemoteMessagingConfig.self, from: responseData) + } catch { + throw RemoteMessageResponse.StatusError.parsingFailed + } + } +} diff --git a/Sources/RemoteMessaging/RemoteMessagingConfigMatcher.swift b/Sources/RemoteMessaging/RemoteMessagingConfigMatcher.swift index 4df18d7a6..b76b0331b 100644 --- a/Sources/RemoteMessaging/RemoteMessagingConfigMatcher.swift +++ b/Sources/RemoteMessaging/RemoteMessagingConfigMatcher.swift @@ -21,18 +21,18 @@ import Foundation public struct RemoteMessagingConfigMatcher { - private let appAttributeMatcher: AppAttributeMatcher - private let deviceAttributeMatcher: DeviceAttributeMatcher - private let userAttributeMatcher: UserAttributeMatcher + private let appAttributeMatcher: AttributeMatching + private let deviceAttributeMatcher: AttributeMatching + private let userAttributeMatcher: AttributeMatching private let percentileStore: RemoteMessagingPercentileStoring private let dismissedMessageIds: [String] let surveyActionMapper: RemoteMessagingSurveyActionMapping - private let matchers: [AttributeMatcher] + private let matchers: [AttributeMatching] - public init(appAttributeMatcher: AppAttributeMatcher, - deviceAttributeMatcher: DeviceAttributeMatcher = DeviceAttributeMatcher(), - userAttributeMatcher: UserAttributeMatcher, + public init(appAttributeMatcher: AttributeMatching, + deviceAttributeMatcher: AttributeMatching = DeviceAttributeMatcher(), + userAttributeMatcher: AttributeMatching, percentileStore: RemoteMessagingPercentileStoring, surveyActionMapper: RemoteMessagingSurveyActionMapping, dismissedMessageIds: [String]) { diff --git a/Sources/RemoteMessaging/RemoteMessagingConfigProcessor.swift b/Sources/RemoteMessaging/RemoteMessagingConfigProcessor.swift index e8afc324d..68d7cfb56 100644 --- a/Sources/RemoteMessaging/RemoteMessagingConfigProcessor.swift +++ b/Sources/RemoteMessaging/RemoteMessagingConfigProcessor.swift @@ -19,18 +19,29 @@ import Common import Foundation -public struct RemoteMessagingConfigProcessor { +/** + * This protocol defines API for processing RMF config file + * in order to find a message to be displayed. + */ +public protocol RemoteMessagingConfigProcessing { + var remoteMessagingConfigMatcher: RemoteMessagingConfigMatcher { get } + + func shouldProcessConfig(_ currentConfig: RemoteMessagingConfig?) -> Bool + + func process( + jsonRemoteMessagingConfig: RemoteMessageResponse.JsonRemoteMessagingConfig, + currentConfig: RemoteMessagingConfig? + ) -> RemoteMessagingConfigProcessor.ProcessorResult? +} + +public struct RemoteMessagingConfigProcessor: RemoteMessagingConfigProcessing { public struct ProcessorResult { public let version: Int64 public let message: RemoteMessageModel? } - let remoteMessagingConfigMatcher: RemoteMessagingConfigMatcher - - public init(remoteMessagingConfigMatcher: RemoteMessagingConfigMatcher) { - self.remoteMessagingConfigMatcher = remoteMessagingConfigMatcher - } + public let remoteMessagingConfigMatcher: RemoteMessagingConfigMatcher public func process(jsonRemoteMessagingConfig: RemoteMessageResponse.JsonRemoteMessagingConfig, currentConfig: RemoteMessagingConfig?) -> ProcessorResult? { @@ -55,7 +66,7 @@ public struct RemoteMessagingConfigProcessor { return nil } - func shouldProcessConfig(_ currentConfig: RemoteMessagingConfig?) -> Bool { + public func shouldProcessConfig(_ currentConfig: RemoteMessagingConfig?) -> Bool { guard let currentConfig = currentConfig else { return true } diff --git a/Sources/RemoteMessaging/RemoteMessagingPercentileStoring.swift b/Sources/RemoteMessaging/RemoteMessagingPercentileStoring.swift index 531ebab8b..3093a26b2 100644 --- a/Sources/RemoteMessaging/RemoteMessagingPercentileStoring.swift +++ b/Sources/RemoteMessaging/RemoteMessagingPercentileStoring.swift @@ -17,6 +17,7 @@ // import Foundation +import Persistence public protocol RemoteMessagingPercentileStoring { func percentile(forMessageId: String) -> Float @@ -28,21 +29,21 @@ public class RemoteMessagingPercentileUserDefaultsStore: RemoteMessagingPercenti static let remoteMessagingPercentileMapping = "com.duckduckgo.app.remoteMessagingPercentileMapping" } - private let userDefaults: UserDefaults + private let keyValueStore: KeyValueStoring - public init(userDefaults: UserDefaults) { - self.userDefaults = userDefaults + public init(keyValueStore: KeyValueStoring) { + self.keyValueStore = keyValueStore } public func percentile(forMessageId messageID: String) -> Float { - var percentileMapping = (userDefaults.dictionary(forKey: Constants.remoteMessagingPercentileMapping) as? [String: Float]) ?? [:] + var percentileMapping = (keyValueStore.object(forKey: Constants.remoteMessagingPercentileMapping) as? [String: Float]) ?? [:] if let percentile = percentileMapping[messageID] { return percentile } else { let newPercentile = Float.random(in: 0...1) percentileMapping[messageID] = newPercentile - userDefaults.set(percentileMapping, forKey: Constants.remoteMessagingPercentileMapping) + keyValueStore.set(percentileMapping, forKey: Constants.remoteMessagingPercentileMapping) return newPercentile } diff --git a/Sources/RemoteMessaging/RemoteMessagingProcessing.swift b/Sources/RemoteMessaging/RemoteMessagingProcessing.swift new file mode 100644 index 000000000..eb14d1803 --- /dev/null +++ b/Sources/RemoteMessaging/RemoteMessagingProcessing.swift @@ -0,0 +1,100 @@ +// +// RemoteMessagingProcessing.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 Common +import Configuration +import Foundation + +/** + * This protocol defines API for providing RMF config matcher + * that contains values of matched attributes that the config + * file is evaluated against. + * + * Client apps should implement it and pass to a class implementing + * RemoteMessagingProcessing. + */ +public protocol RemoteMessagingConfigMatcherProviding { + func refreshConfigMatcher(using store: RemoteMessagingStoring) async -> RemoteMessagingConfigMatcher +} + +/** + * This protocol defines API for Remote Messaging client in the app. + */ +public protocol RemoteMessagingProcessing { + /// Defines endpoint URL where the config file is available. + var endpoint: URL { get } + + /// This holds the fetcher that downloads the config file from the server. + var configFetcher: RemoteMessagingConfigFetching { get } + + /// This holds the config matcher provider that updates the config matcher before the config is evaluated. + var configMatcherProvider: RemoteMessagingConfigMatcherProviding { get } + + /// Provides feature flag support for RMF. + var remoteMessagingAvailabilityProvider: RemoteMessagingAvailabilityProviding { get } + + /** + * This function returns a config processor. + * + * Config processor performs evaluation of the JSON config file against the matcher containing + * app-specific, device-specific and user-specific matched attributes. Default implementation is provided. + */ + func configProcessor(for configMatcher: RemoteMessagingConfigMatcher) -> RemoteMessagingConfigProcessing + + /** + * This is the entry point to RMF from the client app. + * + * This function fetches the config, updates config matcher, evaluates the config against the matcher + * and stores the result as needed. Client apps should call this function in order to refresh remote messages. + * When messages are updated, `RemoteMessagingStore.Notifications.remoteMessagesDidChange` notification is posted. + * Default implementation is provided. + */ + func fetchAndProcess(using store: RemoteMessagingStoring) async throws +} + +public extension RemoteMessagingProcessing { + + func configProcessor(for configMatcher: RemoteMessagingConfigMatcher) -> RemoteMessagingConfigProcessing { + RemoteMessagingConfigProcessor(remoteMessagingConfigMatcher: configMatcher) + } + + func fetchAndProcess(using store: RemoteMessagingStoring) async throws { + guard remoteMessagingAvailabilityProvider.isRemoteMessagingAvailable else { + os_log("Remote messaging feature flag is disabled, skipping fetching messages", log: .remoteMessaging, type: .debug) + return + } + do { + let jsonConfig = try await configFetcher.fetchRemoteMessagingConfig() + os_log("Successfully fetched remote messages", log: .remoteMessaging, type: .debug) + + let remoteMessagingConfigMatcher = await configMatcherProvider.refreshConfigMatcher(using: store) + + let processor = configProcessor(for: remoteMessagingConfigMatcher) + let storedConfig = store.fetchRemoteMessagingConfig() + + if let processorResult = processor.process(jsonRemoteMessagingConfig: jsonConfig, currentConfig: storedConfig) { + store.saveProcessedResult(processorResult) + } + + } catch { + os_log("Failed to fetch remote messages", log: .remoteMessaging, type: .error) + throw error + } + } +} diff --git a/Sources/RemoteMessaging/RemoteMessagingStore.swift b/Sources/RemoteMessaging/RemoteMessagingStore.swift index 4f13be81b..615e6dc18 100644 --- a/Sources/RemoteMessaging/RemoteMessagingStore.swift +++ b/Sources/RemoteMessaging/RemoteMessagingStore.swift @@ -17,6 +17,7 @@ // import Foundation +import Combine import Common import CoreData import BrowserServicesKit @@ -47,53 +48,70 @@ public final class RemoteMessagingStore: RemoteMessagingStoring { public static let privateContextName = "RemoteMessaging" } - let context: NSManagedObjectContext + let database: CoreDataDatabase let notificationCenter: NotificationCenter - - public convenience init( - database: CoreDataDatabase, - notificationCenter: NotificationCenter = .default, - errorEvents: EventMapping?, - log: @escaping @autoclosure () -> OSLog = .disabled - ) { - self.init( - context: database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName), - notificationCenter: notificationCenter, - errorEvents: errorEvents, - log: log() - ) - } + let remoteMessagingAvailabilityProvider: RemoteMessagingAvailabilityProviding public init( - context: NSManagedObjectContext, + database: CoreDataDatabase, notificationCenter: NotificationCenter = .default, errorEvents: EventMapping?, + remoteMessagingAvailabilityProvider: RemoteMessagingAvailabilityProviding, log: @escaping @autoclosure () -> OSLog = .disabled ) { - self.context = context + self.database = database self.notificationCenter = notificationCenter self.errorEvents = errorEvents + self.remoteMessagingAvailabilityProvider = remoteMessagingAvailabilityProvider self.getLog = log + + featureFlagDisabledCancellable = remoteMessagingAvailabilityProvider.isRemoteMessagingAvailablePublisher + .map { !$0 } + .removeDuplicates() + .sink { [weak self] _ in + self?.deleteScheduledMessagesIfNeeded() + } } public func saveProcessedResult(_ processorResult: RemoteMessagingConfigProcessor.ProcessorResult) { + guard remoteMessagingAvailabilityProvider.isRemoteMessagingAvailable else { + os_log( + "Remote messaging feature flag is disabled, skipping saving processed version: %d", + log: log, + type: .debug, + processorResult.version + ) + return + } os_log("Remote messaging config - save processed version: %d", log: log, type: .debug, processorResult.version) - saveRemoteMessagingConfig(withVersion: processorResult.version) + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName) + saveRemoteMessagingConfig(withVersion: processorResult.version, in: context) if let remoteMessage = processorResult.message { - deleteScheduledRemoteMessages() - save(remoteMessage: remoteMessage) + deleteScheduledRemoteMessages(in: context) + save(remoteMessage: remoteMessage, in: context) } else { - deleteScheduledRemoteMessages() + deleteScheduledRemoteMessages(in: context) } + notificationCenter.post(name: Notifications.remoteMessagesDidChange, object: nil) + } - DispatchQueue.main.async { - self.notificationCenter.post(name: Notifications.remoteMessagesDidChange, object: nil) + private func deleteScheduledMessagesIfNeeded() { + guard fetchScheduledRemoteMessage() != nil else { + return } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName) + + invalidateRemoteMessagingConfigs(in: context) + deleteScheduledRemoteMessages(in: context) + notificationCenter.post(name: Notifications.remoteMessagesDidChange, object: nil) } private let errorEvents: EventMapping? + private var featureFlagDisabledCancellable: AnyCancellable? private let getLog: () -> OSLog private var log: OSLog { @@ -106,7 +124,12 @@ public final class RemoteMessagingStore: RemoteMessagingStoring { extension RemoteMessagingStore { public func fetchRemoteMessagingConfig() -> RemoteMessagingConfig? { + guard remoteMessagingAvailabilityProvider.isRemoteMessagingAvailable else { + return nil + } + var config: RemoteMessagingConfig? + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName) context.performAndWait { let fetchRequest = RemoteMessagingConfigManagedObject.fetchRequest() fetchRequest.fetchLimit = 1 @@ -129,7 +152,7 @@ extension RemoteMessagingStore { extension RemoteMessagingStore { - private func saveRemoteMessagingConfig(withVersion version: Int64) { + private func saveRemoteMessagingConfig(withVersion version: Int64, in context: NSManagedObjectContext) { context.performAndWait { let fetchRequest: NSFetchRequest = RemoteMessagingConfigManagedObject.fetchRequest() fetchRequest.fetchLimit = 1 @@ -156,7 +179,7 @@ extension RemoteMessagingStore { } } - private func invalidateRemoteMessagingConfigs() { + private func invalidateRemoteMessagingConfigs(in context: NSManagedObjectContext) { context.performAndWait { let fetchRequest: NSFetchRequest = RemoteMessagingConfigManagedObject.fetchRequest() fetchRequest.returnsObjectsAsFaults = false @@ -182,7 +205,12 @@ extension RemoteMessagingStore { extension RemoteMessagingStore { public func fetchScheduledRemoteMessage() -> RemoteMessageModel? { + guard remoteMessagingAvailabilityProvider.isRemoteMessagingAvailable else { + return nil + } + var scheduledRemoteMessage: RemoteMessageModel? + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName) context.performAndWait { let fetchRequest: NSFetchRequest = RemoteMessageManagedObject.fetchRequest() fetchRequest.predicate = NSPredicate(format: "status == %i", RemoteMessageStatus.scheduled.rawValue) @@ -205,8 +233,13 @@ extension RemoteMessagingStore { return scheduledRemoteMessage } - public func fetchRemoteMessage(withId id: String) -> RemoteMessageModel? { + public func fetchRemoteMessage(withID id: String) -> RemoteMessageModel? { + guard remoteMessagingAvailabilityProvider.isRemoteMessagingAvailable else { + return nil + } + var remoteMessage: RemoteMessageModel? + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName) context.performAndWait { let fetchRequest: NSFetchRequest = RemoteMessageManagedObject.fetchRequest() fetchRequest.predicate = NSPredicate(format: "id == %@", id) @@ -229,8 +262,13 @@ extension RemoteMessagingStore { return remoteMessage } - public func hasShownRemoteMessage(withId id: String) -> Bool { + public func hasShownRemoteMessage(withID id: String) -> Bool { + guard remoteMessagingAvailabilityProvider.isRemoteMessagingAvailable else { + return false + } + var shown: Bool = true + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName) context.performAndWait { let fetchRequest: NSFetchRequest = RemoteMessageManagedObject.fetchRequest() fetchRequest.fetchLimit = 1 @@ -245,8 +283,13 @@ extension RemoteMessagingStore { return shown } - public func hasDismissedRemoteMessage(withId id: String) -> Bool { + public func hasDismissedRemoteMessage(withID id: String) -> Bool { + guard remoteMessagingAvailabilityProvider.isRemoteMessagingAvailable else { + return false + } + var dismissed: Bool = true + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName) context.performAndWait { let fetchRequest: NSFetchRequest = RemoteMessageManagedObject.fetchRequest() fetchRequest.fetchLimit = 1 @@ -261,15 +304,25 @@ extension RemoteMessagingStore { return dismissed } - public func dismissRemoteMessage(withId id: String) { + public func dismissRemoteMessage(withID id: String) { + guard remoteMessagingAvailabilityProvider.isRemoteMessagingAvailable else { + return + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName) context.performAndWait { - updateRemoteMessage(withId: id, toStatus: .dismissed) - invalidateRemoteMessagingConfigs() + updateRemoteMessage(withID: id, toStatus: .dismissed, in: context) + invalidateRemoteMessagingConfigs(in: context) } } - public func fetchDismissedRemoteMessageIds() -> [String] { + public func fetchDismissedRemoteMessageIDs() -> [String] { + guard remoteMessagingAvailabilityProvider.isRemoteMessagingAvailable else { + return [] + } + var dismissedMessageIds: [String] = [] + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName) context.performAndWait { let fetchRequest: NSFetchRequest = RemoteMessageManagedObject.fetchRequest() fetchRequest.predicate = NSPredicate(format: "status == %i", RemoteMessageStatus.dismissed.rawValue) @@ -285,7 +338,12 @@ extension RemoteMessagingStore { return dismissedMessageIds } - public func updateRemoteMessage(withId id: String, asShown shown: Bool) { + public func updateRemoteMessage(withID id: String, asShown shown: Bool) { + guard remoteMessagingAvailabilityProvider.isRemoteMessagingAvailable else { + return + } + + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType, name: Constants.privateContextName) context.performAndWait { let fetchRequest: NSFetchRequest = RemoteMessageManagedObject.fetchRequest() fetchRequest.predicate = NSPredicate(format: "id == %@", id) @@ -308,7 +366,7 @@ extension RemoteMessagingStore { extension RemoteMessagingStore { - private func save(remoteMessage: RemoteMessageModel) { + private func save(remoteMessage: RemoteMessageModel, in context: NSManagedObjectContext) { context.performAndWait { let remoteMessageManagedObject = RemoteMessageManagedObject(context: context) @@ -326,7 +384,7 @@ extension RemoteMessagingStore { } } - private func updateRemoteMessage(withId id: String, toStatus status: RemoteMessageStatus) { + private func updateRemoteMessage(withID id: String, toStatus status: RemoteMessageStatus, in context: NSManagedObjectContext) { context.performAndWait { let fetchRequest: NSFetchRequest = RemoteMessageManagedObject.fetchRequest() fetchRequest.predicate = NSPredicate(format: "id == %@", id) @@ -345,7 +403,7 @@ extension RemoteMessagingStore { } } - private func deleteScheduledRemoteMessages() { + private func deleteScheduledRemoteMessages(in context: NSManagedObjectContext) { context.performAndWait { let fetchRequest: NSFetchRequest = RemoteMessageManagedObject.fetchRequest() fetchRequest.returnsObjectsAsFaults = false diff --git a/Sources/RemoteMessaging/RemoteMessagingStoring.swift b/Sources/RemoteMessaging/RemoteMessagingStoring.swift index 1cd1bb2da..321d20bbe 100644 --- a/Sources/RemoteMessaging/RemoteMessagingStoring.swift +++ b/Sources/RemoteMessaging/RemoteMessagingStoring.swift @@ -23,11 +23,11 @@ public protocol RemoteMessagingStoring { func saveProcessedResult(_ processorResult: RemoteMessagingConfigProcessor.ProcessorResult) func fetchRemoteMessagingConfig() -> RemoteMessagingConfig? func fetchScheduledRemoteMessage() -> RemoteMessageModel? - func fetchRemoteMessage(withId id: String) -> RemoteMessageModel? - func hasShownRemoteMessage(withId id: String) -> Bool - func hasDismissedRemoteMessage(withId id: String) -> Bool - func dismissRemoteMessage(withId id: String) - func fetchDismissedRemoteMessageIds() -> [String] - func updateRemoteMessage(withId id: String, asShown shown: Bool) + func fetchRemoteMessage(withID id: String) -> RemoteMessageModel? + func hasShownRemoteMessage(withID id: String) -> Bool + func hasDismissedRemoteMessage(withID id: String) -> Bool + func dismissRemoteMessage(withID id: String) + func fetchDismissedRemoteMessageIDs() -> [String] + func updateRemoteMessage(withID id: String, asShown shown: Bool) } diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessagePercentileStore.swift b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagePercentileStore.swift similarity index 67% rename from Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessagePercentileStore.swift rename to Sources/RemoteMessagingTestsUtils/MockRemoteMessagePercentileStore.swift index f484d04e0..6943dd8fb 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessagePercentileStore.swift +++ b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagePercentileStore.swift @@ -19,12 +19,17 @@ import Foundation import RemoteMessaging -class MockRemoteMessagePercentileStore: RemoteMessagingPercentileStoring { +public class MockRemoteMessagePercentileStore: RemoteMessagingPercentileStoring { - var percentileStorage: [String: Float] = [:] - var defaultPercentage: Float = 0 + public var percentileStorage: [String: Float] + public var defaultPercentage: Float - func percentile(forMessageId messageID: String) -> Float { + public init(percentileStorage: [String: Float] = [:], defaultPercentage: Float = 0) { + self.percentileStorage = percentileStorage + self.defaultPercentage = defaultPercentage + } + + public func percentile(forMessageId messageID: String) -> Float { if let percentile = percentileStorage[messageID] { return percentile } diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessageSurveyActionMapper.swift b/Sources/RemoteMessagingTestsUtils/MockRemoteMessageSurveyActionMapper.swift similarity index 77% rename from Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessageSurveyActionMapper.swift rename to Sources/RemoteMessagingTestsUtils/MockRemoteMessageSurveyActionMapper.swift index 07f9ec4d0..28b9427ae 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessageSurveyActionMapper.swift +++ b/Sources/RemoteMessagingTestsUtils/MockRemoteMessageSurveyActionMapper.swift @@ -19,9 +19,11 @@ import Foundation import RemoteMessaging -class MockRemoteMessageSurveyActionMapper: RemoteMessagingSurveyActionMapping { +public class MockRemoteMessageSurveyActionMapper: RemoteMessagingSurveyActionMapping { - func add(parameters: [RemoteMessaging.RemoteMessagingSurveyActionParameter], to url: URL) -> URL { + public init() {} + + public func add(parameters: [RemoteMessaging.RemoteMessagingSurveyActionParameter], to url: URL) -> URL { return url } diff --git a/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingAvailabilityProvider.swift b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingAvailabilityProvider.swift new file mode 100644 index 000000000..1014f734f --- /dev/null +++ b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingAvailabilityProvider.swift @@ -0,0 +1,33 @@ +// +// MockRemoteMessagingAvailabilityProvider.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 RemoteMessaging + +public class MockRemoteMessagingAvailabilityProvider: RemoteMessagingAvailabilityProviding { + + public init(isRemoteMessagingAvailable: Bool = true) { + self.isRemoteMessagingAvailable = isRemoteMessagingAvailable + } + + public var isRemoteMessagingAvailablePublisher: AnyPublisher { + $isRemoteMessagingAvailable.dropFirst().eraseToAnyPublisher() + } + + @Published public var isRemoteMessagingAvailable: Bool +} diff --git a/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingConfigFetcher.swift b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingConfigFetcher.swift new file mode 100644 index 000000000..5542b6d5d --- /dev/null +++ b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingConfigFetcher.swift @@ -0,0 +1,41 @@ +// +// MockRemoteMessagingConfigFetcher.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 +@testable import RemoteMessaging + +public class MockRemoteMessagingConfigFetcher: RemoteMessagingConfigFetching { + + public init(config: RemoteMessageResponse.JsonRemoteMessagingConfig = .empty) { + self.config = config + } + + public var error: Error? + public var config: RemoteMessageResponse.JsonRemoteMessagingConfig + + public func fetchRemoteMessagingConfig() async throws -> RemoteMessageResponse.JsonRemoteMessagingConfig { + if let error { + throw error + } + return config + } +} + +public extension RemoteMessageResponse.JsonRemoteMessagingConfig { + static let empty: Self = .init(version: 0, messages: [], rules: []) +} diff --git a/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingConfigMatcherProvider.swift b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingConfigMatcherProvider.swift new file mode 100644 index 000000000..fd041e8b3 --- /dev/null +++ b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingConfigMatcherProvider.swift @@ -0,0 +1,33 @@ +// +// MockRemoteMessagingConfigMatcherProvider.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import RemoteMessaging + +public class MockRemoteMessagingConfigMatcherProvider: RemoteMessagingConfigMatcherProviding { + + public init(refresh: @escaping (RemoteMessagingStoring) -> RemoteMessagingConfigMatcher) { + self.refresh = refresh + } + + public func refreshConfigMatcher(using store: RemoteMessagingStoring) async -> RemoteMessagingConfigMatcher { + await refresh(store) + } + + public var refresh: (RemoteMessagingStoring) async -> RemoteMessagingConfigMatcher +} diff --git a/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingConfigProcessor.swift b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingConfigProcessor.swift new file mode 100644 index 000000000..9568b5f06 --- /dev/null +++ b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingConfigProcessor.swift @@ -0,0 +1,47 @@ +// +// MockRemoteMessagingConfigProcessor.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import RemoteMessaging + +public class MockRemoteMessagingConfigProcessor: RemoteMessagingConfigProcessing { + public var shouldProcessConfig: Bool + + public var processConfig: ( + RemoteMessageResponse.JsonRemoteMessagingConfig, RemoteMessagingConfig? + ) -> RemoteMessagingConfigProcessor.ProcessorResult? = { _, _ in return nil } + + public var remoteMessagingConfigMatcher: RemoteMessagingConfigMatcher + + public init(shouldProcessConfig: Bool = true, remoteMessagingConfigMatcher: RemoteMessagingConfigMatcher) { + self.shouldProcessConfig = shouldProcessConfig + self.remoteMessagingConfigMatcher = remoteMessagingConfigMatcher + } + + public func shouldProcessConfig(_ currentConfig: RemoteMessagingConfig?) -> Bool { + shouldProcessConfig + } + + public func process( + jsonRemoteMessagingConfig: RemoteMessageResponse.JsonRemoteMessagingConfig, + currentConfig: RemoteMessagingConfig? + ) -> RemoteMessagingConfigProcessor.ProcessorResult? { + + processConfig(jsonRemoteMessagingConfig, currentConfig) + } +} diff --git a/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingStore.swift b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingStore.swift new file mode 100644 index 000000000..bbdb5e92a --- /dev/null +++ b/Sources/RemoteMessagingTestsUtils/MockRemoteMessagingStore.swift @@ -0,0 +1,95 @@ +// +// MockRemoteMessagingStore.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import RemoteMessaging + +public class MockRemoteMessagingStore: RemoteMessagingStoring { + + public var saveProcessedResultCalls = 0 + public var fetchRemoteMessagingConfigCalls = 0 + public var fetchScheduledRemoteMessageCalls = 0 + public var fetchRemoteMessageCalls = 0 + public var hasShownRemoteMessageCalls = 0 + public var hasDismissedRemoteMessageCalls = 0 + public var dismissRemoteMessageCalls = 0 + public var fetchDismissedRemoteMessageIDsCalls = 0 + public var updateRemoteMessageCalls = 0 + + public var remoteMessagingConfig: RemoteMessagingConfig? + public var scheduledRemoteMessage: RemoteMessageModel? + public var remoteMessages: [String: RemoteMessageModel] + public var shownRemoteMessagesIDs: [String] + public var dismissedRemoteMessagesIDs: [String] + + public init( + remoteMessagingConfig: RemoteMessagingConfig? = nil, + scheduledRemoteMessage: RemoteMessageModel? = nil, + remoteMessages: [String: RemoteMessageModel] = [:], + shownRemoteMessagesIDs: [String] = [], + dismissedRemoteMessagesIDs: [String] = [] + ) { + self.remoteMessagingConfig = remoteMessagingConfig + self.scheduledRemoteMessage = scheduledRemoteMessage + self.remoteMessages = remoteMessages + self.shownRemoteMessagesIDs = shownRemoteMessagesIDs + self.dismissedRemoteMessagesIDs = dismissedRemoteMessagesIDs + } + + public func saveProcessedResult(_ processorResult: RemoteMessagingConfigProcessor.ProcessorResult) { + saveProcessedResultCalls += 1 + } + + public func fetchRemoteMessagingConfig() -> RemoteMessagingConfig? { + fetchRemoteMessagingConfigCalls += 1 + return remoteMessagingConfig + } + + public func fetchScheduledRemoteMessage() -> RemoteMessageModel? { + fetchScheduledRemoteMessageCalls += 1 + return scheduledRemoteMessage + } + + public func fetchRemoteMessage(withID id: String) -> RemoteMessageModel? { + fetchRemoteMessageCalls += 1 + return remoteMessages[id] + } + + public func hasShownRemoteMessage(withID id: String) -> Bool { + hasShownRemoteMessageCalls += 1 + return shownRemoteMessagesIDs.contains(id) + } + + public func hasDismissedRemoteMessage(withID id: String) -> Bool { + hasDismissedRemoteMessageCalls += 1 + return dismissedRemoteMessagesIDs.contains(id) + } + + public func dismissRemoteMessage(withID id: String) { + dismissRemoteMessageCalls += 1 + } + + public func fetchDismissedRemoteMessageIDs() -> [String] { + fetchDismissedRemoteMessageIDsCalls += 1 + return dismissedRemoteMessagesIDs + } + + public func updateRemoteMessage(withID id: String, asShown shown: Bool) { + updateRemoteMessageCalls += 1 + } +} diff --git a/Tests/BookmarksTests/BookmarkUtilsTests.swift b/Tests/BookmarksTests/BookmarkUtilsTests.swift index 67831e1c2..5335cd883 100644 --- a/Tests/BookmarksTests/BookmarkUtilsTests.swift +++ b/Tests/BookmarksTests/BookmarkUtilsTests.swift @@ -127,4 +127,77 @@ final class BookmarkUtilsTests: XCTestCase { }) } } + + func testThatNumberOfBookmarksSkipsFoldersAndIncludesFavorites() { + + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + BookmarkUtils.prepareFoldersStructure(in: context) + try! context.save() + } + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Folder(id: "2") { + Bookmark(id: "3") + Bookmark(id: "4") + Folder(id: "5") { + Folder(id: "6") { + Bookmark(id: "7", favoritedOn: [.desktop, .unified]) + } + Folder(id: "8") + Bookmark(id: "9", favoritedOn: [.desktop, .unified]) + Bookmark(id: "10", favoritedOn: [.desktop, .unified]) + } + } + Bookmark(id: "11") + Bookmark(id: "12") + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + + try! context.save() + + XCTAssertEqual(BookmarkUtils.numberOfBookmarks(in: context), 8) + } + } + + func testThatNumberOfFavoritesHonorsDisplayMode() { + + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + BookmarkUtils.prepareFoldersStructure(in: context) + try! context.save() + } + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Folder(id: "2") { + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4") + Folder(id: "5") { + Folder(id: "6") { + Bookmark(id: "7", favoritedOn: [.desktop, .unified]) + } + Folder(id: "8") + Bookmark(id: "9", favoritedOn: [.mobile, .unified]) + Bookmark(id: "10", favoritedOn: [.desktop, .unified]) + } + } + Bookmark(id: "11", favoritedOn: [.desktop, .unified]) + Bookmark(id: "12") + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + + try! context.save() + + XCTAssertEqual(BookmarkUtils.numberOfFavorites(for: .displayNative(.desktop), in: context), 3) + XCTAssertEqual(BookmarkUtils.numberOfFavorites(for: .displayNative(.mobile), in: context), 2) + XCTAssertEqual(BookmarkUtils.numberOfFavorites(for: .displayUnified(native: .desktop), in: context), 5) + XCTAssertEqual(BookmarkUtils.numberOfFavorites(for: .displayUnified(native: .mobile), in: context), 5) + } + } } diff --git a/Tests/BrowserServicesKitTests/Email/EmailManagerTests.swift b/Tests/BrowserServicesKitTests/Email/EmailManagerTests.swift index 3401fc473..0ec6c1934 100644 --- a/Tests/BrowserServicesKitTests/Email/EmailManagerTests.swift +++ b/Tests/BrowserServicesKitTests/Email/EmailManagerTests.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import BrowserServicesKitTestsUtils import XCTest @testable import BrowserServicesKit @@ -116,7 +117,9 @@ class EmailManagerTests: XCTestCase { let expect = expectation(description: "test") let storage = storageForGetAliasTest(signedIn: true, storedAlias: true, fulfillOnFirstStorageEvent: true, expectationToFulfill: expect) let emailManager = EmailManager(storage: storage) - let requestDelegate = MockEmailManagerRequestDelegate() + let requestDelegate = MockEmailManagerRequestDelegate { + events.append(.aliasRequestMade) + } requestDelegate.mockAliases = ["testAlias2", "testAlias3"] emailManager.requestDelegate = requestDelegate @@ -149,7 +152,7 @@ class EmailManagerTests: XCTestCase { let expect = expectation(description: "test") let storage = storageForGetAliasTest(signedIn: true, storedAlias: false, fulfillOnFirstStorageEvent: false, expectationToFulfill: expect) let emailManager = EmailManager(storage: storage) - let requestDelegate = MockEmailManagerRequestDelegate() + let requestDelegate = makeMockRequestDelegate() requestDelegate.mockAliases = ["testAlias2", "testAlias3"] emailManager.requestDelegate = requestDelegate @@ -184,7 +187,7 @@ class EmailManagerTests: XCTestCase { let expect = expectation(description: "test") let storage = storageForGetAliasTest(signedIn: false, storedAlias: false, fulfillOnFirstStorageEvent: false, expectationToFulfill: expect) let emailManager = EmailManager(storage: storage) - let requestDelegate = MockEmailManagerRequestDelegate() + let requestDelegate = makeMockRequestDelegate() requestDelegate.mockAliases = ["testAlias2", "testAlias3"] emailManager.requestDelegate = requestDelegate @@ -210,7 +213,7 @@ class EmailManagerTests: XCTestCase { let expect = expectation(description: "test") let storage = storageForGetAliasTest(signedIn: true, storedAlias: false, fulfillOnFirstStorageEvent: true, expectationToFulfill: expect) let emailManager = EmailManager(storage: storage) - let requestDelegate = MockEmailManagerRequestDelegate() + let requestDelegate = makeMockRequestDelegate() requestDelegate.mockAliases = ["testAlias2", "testAlias3"] emailManager.requestDelegate = requestDelegate @@ -235,7 +238,7 @@ class EmailManagerTests: XCTestCase { let expect = expectation(description: "test") let storage = storageForGetAliasTest(signedIn: true, storedAlias: false, fulfillOnFirstStorageEvent: true, expectationToFulfill: expect) let emailManager = EmailManager(storage: storage) - let requestDelegate = MockEmailManagerRequestDelegate() + let requestDelegate = makeMockRequestDelegate() requestDelegate.mockAliases = ["testAlias2", "testAlias3"] emailManager.requestDelegate = requestDelegate @@ -264,7 +267,7 @@ class EmailManagerTests: XCTestCase { let storage = storageForGetAliasTest(signedIn: true, storedAlias: false, fulfillOnFirstStorageEvent: true, expectationToFulfill: expect) storage.mockToken = "token" let emailManager = EmailManager(storage: storage) - let requestDelegate = MockEmailManagerRequestDelegate() + let requestDelegate = makeMockRequestDelegate() requestDelegate.mockAliases = ["testAlias2", "testAlias3"] emailManager.requestDelegate = requestDelegate @@ -364,7 +367,7 @@ class EmailManagerTests: XCTestCase { storage.mockUsername = username let emailManager = EmailManager(storage: storage) - let requestDelegate = MockEmailManagerRequestDelegate() + let requestDelegate = makeMockRequestDelegate() emailManager.requestDelegate = requestDelegate XCTAssertNil(emailManager.userEmail) @@ -372,111 +375,9 @@ class EmailManagerTests: XCTestCase { XCTAssertEqual(requestDelegate.keychainAccessError, .keychainLookupFailure(errSecInternalError)) } -} - -class MockEmailManagerRequestDelegate: EmailManagerRequestDelegate { - var activeTask: URLSessionTask? - var mockAliases: [String] = [] - var waitlistTimestamp: Int = 1 - - // swiftlint:disable function_parameter_count - func emailManager(_ emailManager: EmailManager, requested url: URL, method: String, headers: [String: String], parameters: [String: String]?, httpBody: Data?, timeoutInterval: TimeInterval) async throws -> Data { - switch url.absoluteString { - case EmailUrls.Url.emailAlias: return try processMockAliasRequest().get() - default: fatalError("\(#file): Unsupported URL passed to mock request delegate: \(url)") - } - } - // swiftlint:enable function_parameter_count - - var keychainAccessErrorAccessType: EmailKeychainAccessType? - var keychainAccessError: EmailKeychainAccessError? - - func emailManagerKeychainAccessFailed(_ emailManager: EmailManager, - accessType: EmailKeychainAccessType, - error: EmailKeychainAccessError) { - keychainAccessErrorAccessType = accessType - keychainAccessError = error - } - - private func processMockAliasRequest() -> Result { - events.append(.aliasRequestMade) - - if mockAliases.first != nil { - let alias = mockAliases.removeFirst() - let jsonString = "{\"address\":\"\(alias)\"}" - let data = jsonString.data(using: .utf8)! - return .success(data) - } else { - return .failure(AliasRequestError.noDataError) + private func makeMockRequestDelegate() -> MockEmailManagerRequestDelegate { + .init { + events.append(.aliasRequestMade) } } - -} - -class MockEmailManagerStorage: EmailManagerStorage { - - var mockError: EmailKeychainAccessError? - - var mockUsername: String? - var mockToken: String? - var mockAlias: String? - var mockCohort: String? - var mockLastUseDate: String? - - var storeTokenCallback: ((String, String, String?) -> Void)? - var storeAliasCallback: ((String) -> Void)? - var storeLastUseDateCallback: ((String) -> Void)? - var deleteAliasCallback: (() -> Void)? - var deleteAuthenticationStateCallback: (() -> Void)? - var deleteWaitlistStateCallback: (() -> Void)? - - func getUsername() throws -> String? { - if let mockError = mockError { throw mockError } - return mockUsername - } - - func getToken() throws -> String? { - if let mockError = mockError { throw mockError } - return mockToken - } - - func getAlias() throws -> String? { - if let mockError = mockError { throw mockError } - return mockAlias - } - - func getCohort() throws -> String? { - if let mockError = mockError { throw mockError } - return mockCohort - } - - func getLastUseDate() throws -> String? { - if let mockError = mockError { throw mockError } - return mockLastUseDate - } - - func store(token: String, username: String, cohort: String?) throws { - storeTokenCallback?(token, username, cohort) - } - - func store(alias: String) throws { - storeAliasCallback?(alias) - } - - func store(lastUseDate: String) throws { - storeLastUseDateCallback?(lastUseDate) - } - - func deleteAlias() { - deleteAliasCallback?() - } - - func deleteAuthenticationState() { - deleteAuthenticationStateCallback?() - } - - func deleteWaitlistState() { - deleteWaitlistStateCallback?() - } - } diff --git a/Tests/ConfigurationTests/Mocks/MockConfigurationURLProvider.swift b/Tests/ConfigurationTests/Mocks/MockConfigurationURLProvider.swift index 460a4a52a..ed6e68d16 100644 --- a/Tests/ConfigurationTests/Mocks/MockConfigurationURLProvider.swift +++ b/Tests/ConfigurationTests/Mocks/MockConfigurationURLProvider.swift @@ -37,6 +37,8 @@ struct MockConfigurationURLProvider: ConfigurationURLProviding { return URL(string: "f")! case .FBConfig: return URL(string: "g")! + case .remoteMessagingConfig: + return URL(string: "h")! } } diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift b/Tests/RemoteMessagingTests/Mappers/JsonToRemoteConfigModelMapperTests.swift similarity index 93% rename from Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift rename to Tests/RemoteMessagingTests/Mappers/JsonToRemoteConfigModelMapperTests.swift index 82d88de45..3a87e411d 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift +++ b/Tests/RemoteMessagingTests/Mappers/JsonToRemoteConfigModelMapperTests.swift @@ -17,15 +17,13 @@ // import XCTest -@testable import BrowserServicesKit +import RemoteMessagingTestsUtils @testable import RemoteMessaging class JsonToRemoteConfigModelMapperTests: XCTestCase { - private var data = JsonTestDataLoader() - func testWhenValidJsonParsedThenMessagesMappedIntoRemoteConfig() throws { - let config = try decodeAndMapJson(fileName: "Resources/remote-messaging-config.json") + let config = try decodeAndMapJson(fileName: "remote-messaging-config.json") XCTAssertEqual(config.messages.count, 8) XCTAssertEqual(config.messages[0], RemoteMessageModel( @@ -101,7 +99,7 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase { } func testWhenValidJsonParsedThenRulesMappedIntoRemoteConfig() throws { - let config = try decodeAndMapJson(fileName: "Resources/remote-messaging-config.json") + let config = try decodeAndMapJson(fileName: "remote-messaging-config.json") XCTAssertTrue(config.rules.count == 6) let rule5 = config.rules.filter { $0.id == 5 }.first @@ -188,13 +186,13 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase { } func testWhenJsonMessagesHaveUnknownTypesThenMessagesNotMappedIntoConfig() throws { - let config = try decodeAndMapJson(fileName: "Resources/remote-messaging-config-unsupported-items.json") + let config = try decodeAndMapJson(fileName: "remote-messaging-config-unsupported-items.json") let countValidContent = config.messages.filter { $0.content != nil }.count XCTAssertEqual(countValidContent, 1) } func testWhenJsonMessagesHaveUnknownTypesThenRulesMappedIntoConfig() throws { - let config = try decodeAndMapJson(fileName: "Resources/remote-messaging-config-unsupported-items.json") + let config = try decodeAndMapJson(fileName: "remote-messaging-config-unsupported-items.json") XCTAssertTrue(config.rules.count == 2) let rule6 = config.rules.filter { $0.id == 6 }.first @@ -211,7 +209,9 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase { } func testWhenJsonAttributeMissingThenUnknownIntoConfig() throws { - let validJson = data.fromJsonFile("Resources/remote-messaging-config-malformed.json") + let resourceURL = Bundle.module.resourceURL!.appendingPathComponent("remote-messaging-config-malformed.json", conformingTo: .json) + let validJson = try Data(contentsOf: resourceURL) + let remoteMessagingConfig = try JSONDecoder().decode(RemoteMessageResponse.JsonRemoteMessagingConfig.self, from: validJson) let surveyMapper = MockRemoteMessageSurveyActionMapper() XCTAssertNotNil(remoteMessagingConfig) @@ -226,7 +226,8 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase { } func decodeAndMapJson(fileName: String) throws -> RemoteConfigModel { - let validJson = data.fromJsonFile(fileName) + let resourceURL = Bundle.module.resourceURL!.appendingPathComponent(fileName, conformingTo: .json) + let validJson = try Data(contentsOf: resourceURL) let remoteMessagingConfig = try JSONDecoder().decode(RemoteMessageResponse.JsonRemoteMessagingConfig.self, from: validJson) let surveyMapper = MockRemoteMessageSurveyActionMapper() XCTAssertNotNil(remoteMessagingConfig) diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/AppAttributeMatcherTests.swift b/Tests/RemoteMessagingTests/Matchers/AppAttributeMatcherTests.swift similarity index 83% rename from Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/AppAttributeMatcherTests.swift rename to Tests/RemoteMessagingTests/Matchers/AppAttributeMatcherTests.swift index 7c18eb2d5..9ca1b09d7 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/AppAttributeMatcherTests.swift +++ b/Tests/RemoteMessagingTests/Matchers/AppAttributeMatcherTests.swift @@ -16,10 +16,11 @@ // limitations under the License. // -import XCTest +import BrowserServicesKitTestsUtils +import Common import Foundation -@testable import Common -@testable import BrowserServicesKit +import RemoteMessagingTestsUtils +import XCTest @testable import RemoteMessaging class AppAttributeMatcherTests: XCTestCase { @@ -97,23 +98,20 @@ class AppAttributeMatcherTests: XCTestCase { func testWhenAppVersionLowerThanMinThenReturnFail() throws { let appVersionComponents = AppVersion.shared.versionAndBuildNumber.components(separatedBy: ".").map { $0 } - let appMajorVersion = appVersionComponents[0] - let greaterThanMax = String(Int(appMajorVersion)! + 1) - let greaterThanMinorVersion = Float(appVersionComponents.suffix(from: 1).joined(separator: "."))! + 0.1 - - let minBumped = "\(appMajorVersion).\(greaterThanMinorVersion)" - XCTAssertEqual(matcher.evaluate(matchingAttribute: AppVersionMatchingAttribute(min: minBumped, max: greaterThanMax, fallback: nil)), + let (major, minor, patch) = (appVersionComponents[0], appVersionComponents[1], appVersionComponents[2]) + let majorBumped = String(Int(major)! + 1) + let patchBumped = [major, minor, String(Int(patch)! + 1)].joined(separator: ".") + XCTAssertEqual(matcher.evaluate(matchingAttribute: AppVersionMatchingAttribute(min: patchBumped, max: majorBumped, fallback: nil)), .fail) } func testWhenAppVersionInRangeThenReturnMatch() throws { let appVersionComponents = AppVersion.shared.versionAndBuildNumber.components(separatedBy: ".").map { $0 } - let appMajorVersion = appVersionComponents[0] - let greaterThanMax = String(Int(appMajorVersion)! + 1) - let lessThanMinorVersion = Float(appVersionComponents.suffix(from: 1).joined(separator: "."))! - 0.1 - let minBumped = "\(appMajorVersion).\(lessThanMinorVersion)" + let (major, minor, patch) = (appVersionComponents[0], appVersionComponents[1], appVersionComponents[2]) + let majorBumped = String(Int(major)! + 1) + let patchDecremented = [major, minor, String(Int(patch)! - 1)].joined(separator: ".") - XCTAssertEqual(matcher.evaluate(matchingAttribute: AppVersionMatchingAttribute(min: minBumped, max: greaterThanMax, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: AppVersionMatchingAttribute(min: patchDecremented, max: majorBumped, fallback: nil)), .match) } @@ -133,11 +131,10 @@ class AppAttributeMatcherTests: XCTestCase { func testWhenAppVersionDifferentToDeviceThenReturnFail() throws { let appVersionComponents = AppVersion.shared.versionAndBuildNumber.components(separatedBy: ".").map { $0 } - let appMajorVersion = appVersionComponents[0] - let lessThanMinorVersion = Float(appVersionComponents.suffix(from: 1).joined(separator: "."))! - 0.1 - let minBumped = "\(appMajorVersion).\(lessThanMinorVersion)" + let (major, minor, patch) = (appVersionComponents[0], appVersionComponents[1], appVersionComponents[2]) + let patchDecremented = [major, minor, String(Int(patch)! - 1)].joined(separator: ".") - XCTAssertEqual(matcher.evaluate(matchingAttribute: AppVersionMatchingAttribute(value: minBumped, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: AppVersionMatchingAttribute(value: patchDecremented, fallback: nil)), .fail) } @@ -182,20 +179,3 @@ class AppAttributeMatcherTests: XCTestCase { } } - -class MockVariant: Variant { - var name: String - - var weight: Int - - var isIncluded: () -> Bool - - var features: [FeatureName] - - init(name: String, weight: Int, isIncluded: @escaping () -> Bool, features: [FeatureName]) { - self.name = name - self.weight = weight - self.isIncluded = isIncluded - self.features = features - } -} diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift b/Tests/RemoteMessagingTests/Matchers/CommonUserAttributeMatcherTests.swift similarity index 54% rename from Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift rename to Tests/RemoteMessagingTests/Matchers/CommonUserAttributeMatcherTests.swift index 98fca95ac..f965d56ce 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift +++ b/Tests/RemoteMessagingTests/Matchers/CommonUserAttributeMatcherTests.swift @@ -1,5 +1,5 @@ // -// UserAttributeMatcherTests.swift +// CommonUserAttributeMatcherTests.swift // // Copyright © 2022 DuckDuckGo. All rights reserved. // @@ -16,17 +16,19 @@ // limitations under the License. // -import XCTest +import BrowserServicesKit +import BrowserServicesKitTestsUtils import Foundation -@testable import BrowserServicesKit +import RemoteMessagingTestsUtils +import XCTest @testable import RemoteMessaging -class UserAttributeMatcherTests: XCTestCase { +class CommonUserAttributeMatcherTests: XCTestCase { var mockStatisticsStore: MockStatisticsStore! var manager: MockVariantManager! var emailManager: EmailManager! - var userAttributeMatcher: UserAttributeMatcher! + var matcher: CommonUserAttributeMatcher! var dateYesterday: Date! override func setUpWithError() throws { @@ -55,185 +57,173 @@ class UserAttributeMatcherTests: XCTestCase { override func tearDownWithError() throws { try super.tearDownWithError() - userAttributeMatcher = nil + matcher = nil } // MARK: - AppTheme func testWhenAppThemeMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: AppThemeMatchingAttribute(value: "default", fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: AppThemeMatchingAttribute(value: "default", fallback: nil)), .match) } func testWhenAppThemeDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: AppThemeMatchingAttribute(value: "light", fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: AppThemeMatchingAttribute(value: "light", fallback: nil)), .fail) } // MARK: - Bookmarks func testWhenBookmarksMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(value: 44, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(value: 44, fallback: nil)), .match) } func testWhenBookmarksDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(value: 22, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(value: 22, fallback: nil)), .fail) } func testWhenBookmarksEqualOrLowerThanMaxThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(max: 44, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(max: 44, fallback: nil)), .match) } func testWhenBookmarksGreaterThanMaxThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(max: 40, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(max: 40, fallback: nil)), .fail) } func testWhenBookmarksLowerThanMinThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(min: 88, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(min: 88, fallback: nil)), .fail) } func testWhenBookmarksInRangeThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(min: 40, max: 48, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(min: 40, max: 48, fallback: nil)), .match) } func testWhenBookmarksNotInRangeThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(min: 47, max: 48, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: BookmarksMatchingAttribute(min: 47, max: 48, fallback: nil)), .fail) } // MARK: - Favorites func testWhenFavoritesMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(value: 88, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(value: 88, fallback: nil)), .match) } func testWhenFavoritesDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(value: 22, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(value: 22, fallback: nil)), .fail) } func testWhenFavoritesEqualOrLowerThanMaxThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(max: 88, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(max: 88, fallback: nil)), .match) } func testWhenFavoritesGreaterThanMaxThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(max: 40, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(max: 40, fallback: nil)), .fail) } func testWhenFavoritesLowerThanMinThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(min: 100, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(min: 100, fallback: nil)), .fail) } func testWhenFavoritesInRangeThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(min: 40, max: 98, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(min: 40, max: 98, fallback: nil)), .match) } func testWhenFavoritesNotInRangeThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(min: 89, max: 98, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: FavoritesMatchingAttribute(min: 89, max: 98, fallback: nil)), .fail) } // MARK: - DaysSinceInstalled func testWhenDaysSinceInstalledEqualOrLowerThanMaxThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(max: 1, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(max: 1, fallback: nil)), .match) } func testWhenDaysSinceInstalledGreaterThanMaxThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(max: 0, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(max: 0, fallback: nil)), .fail) } func testWhenDaysSinceInstalledEqualOrGreaterThanMinThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(min: 1, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(min: 1, fallback: nil)), .match) } func testWhenDaysSinceInstalledLowerThanMinThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(min: 2, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(min: 2, fallback: nil)), .fail) } func testWhenDaysSinceInstalledInRangeThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(min: 0, max: 1, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(min: 0, max: 1, fallback: nil)), .match) } func testWhenDaysSinceInstalledNotInRangeThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(min: 2, max: 44, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: DaysSinceInstalledMatchingAttribute(min: 2, max: 44, fallback: nil)), .fail) } // MARK: - EmailEnabled func testWhenEmailEnabledMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: EmailEnabledMatchingAttribute(value: true, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: EmailEnabledMatchingAttribute(value: true, fallback: nil)), .match) } func testWhenEmailEnabledDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: EmailEnabledMatchingAttribute(value: false, fallback: nil)), - .fail) - } - - // MARK: - WidgetAdded - - func testWhenWidgetAddedMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: WidgetAddedMatchingAttribute(value: true, fallback: nil)), - .match) - } - - func testWhenWidgetAddedDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: WidgetAddedMatchingAttribute(value: false, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: EmailEnabledMatchingAttribute(value: false, fallback: nil)), .fail) } // MARK: - Privacy Pro func testWhenDaysSinceNetPEnabledMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceNetPEnabledMatchingAttribute(min: 1, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: DaysSinceNetPEnabledMatchingAttribute(min: 1, fallback: nil)), .match) } func testWhenDaysSinceNetPEnabledDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceNetPEnabledMatchingAttribute(min: 7, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: DaysSinceNetPEnabledMatchingAttribute(min: 7, fallback: nil)), .fail) } func testWhenIsPrivacyProEligibleUserMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: IsPrivacyProEligibleUserMatchingAttribute(value: true, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: IsPrivacyProEligibleUserMatchingAttribute(value: true, fallback: nil)), .match) } func testWhenIsPrivacyProEligibleUserDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: IsPrivacyProEligibleUserMatchingAttribute(value: false, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: IsPrivacyProEligibleUserMatchingAttribute(value: false, fallback: nil)), .fail) } func testWhenIsPrivacyProSubscriberMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: IsPrivacyProSubscriberUserMatchingAttribute(value: true, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: IsPrivacyProSubscriberUserMatchingAttribute(value: true, fallback: nil)), .match) } func testWhenIsPrivacyProSubscriberDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: IsPrivacyProSubscriberUserMatchingAttribute(value: false, fallback: nil)), + XCTAssertEqual(matcher.evaluate(matchingAttribute: IsPrivacyProSubscriberUserMatchingAttribute(value: false, fallback: nil)), .fail) } func testWhenPrivacyProPurchasePlatformMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate( + XCTAssertEqual(matcher.evaluate( matchingAttribute: PrivacyProPurchasePlatformMatchingAttribute( value: ["apple"], fallback: nil ) @@ -241,7 +231,7 @@ class UserAttributeMatcherTests: XCTestCase { } func testWhenPrivacyProPurchasePlatformDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate( + XCTAssertEqual(matcher.evaluate( matchingAttribute: PrivacyProPurchasePlatformMatchingAttribute( value: ["stripe"], fallback: nil ) @@ -249,79 +239,80 @@ class UserAttributeMatcherTests: XCTestCase { } func testWhenPrivacyProSubscriptionStatusMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate( + XCTAssertEqual(matcher.evaluate( matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: ["active"], fallback: nil) ), .match) } func testWhenPrivacyProSubscriptionStatusHasMultipleAttributesAndOneMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate( + XCTAssertEqual(matcher.evaluate( matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: ["active", "expiring", "expired"], fallback: nil) ), .match) } func testWhenPrivacyProSubscriptionStatusDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate( + XCTAssertEqual(matcher.evaluate( matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: ["expiring"], fallback: nil) ), .fail) } func testWhenPrivacyProSubscriptionStatusHasUnsupportedStatusThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate( + XCTAssertEqual(matcher.evaluate( matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: ["unsupported_status"], fallback: nil) ), .fail) } func testWhenOneDismissedMessageIdMatchesThenReturnMatch() throws { setUpUserAttributeMatcher(dismissedMessageIds: ["1"]) - XCTAssertEqual(userAttributeMatcher.evaluate( + XCTAssertEqual(matcher.evaluate( matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["1", "2", "3"], fallback: nil) ), .match) } func testWhenAllDismissedMessageIdsMatchThenReturnMatch() throws { setUpUserAttributeMatcher(dismissedMessageIds: ["1", "2", "3"]) - XCTAssertEqual(userAttributeMatcher.evaluate( + XCTAssertEqual(matcher.evaluate( matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["1", "2", "3"], fallback: nil) ), .match) } func testWhenNoDismissedMessageIdsMatchThenReturnFail() throws { setUpUserAttributeMatcher(dismissedMessageIds: ["1", "2", "3"]) - XCTAssertEqual(userAttributeMatcher.evaluate( + XCTAssertEqual(matcher.evaluate( matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["4", "5"], fallback: nil) ), .fail) } func testWhenHaveDismissedMessageIdsAndMatchAttributeIsEmptyThenReturnFail() throws { setUpUserAttributeMatcher(dismissedMessageIds: ["1", "2", "3"]) - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: InteractedWithMessageMatchingAttribute(value: [], fallback: nil)), .fail) + XCTAssertEqual(matcher.evaluate(matchingAttribute: InteractedWithMessageMatchingAttribute(value: [], fallback: nil)), .fail) } func testWhenHaveNoDismissedMessageIdsAndMatchAttributeIsNotEmptyThenReturnFail() throws { setUpUserAttributeMatcher(dismissedMessageIds: []) - XCTAssertEqual(userAttributeMatcher.evaluate( + XCTAssertEqual(matcher.evaluate( matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["1", "2"], fallback: nil) ), .fail) } private func setUpUserAttributeMatcher(dismissedMessageIds: [String] = []) { - userAttributeMatcher = UserAttributeMatcher(statisticsStore: mockStatisticsStore, - variantManager: manager, - emailManager: emailManager, - bookmarksCount: 44, - favoritesCount: 88, - appTheme: "default", - isWidgetInstalled: true, - daysSinceNetPEnabled: 3, - isPrivacyProEligibleUser: true, - isPrivacyProSubscriber: true, - privacyProDaysSinceSubscribed: 5, - privacyProDaysUntilExpiry: 25, - privacyProPurchasePlatform: "apple", - isPrivacyProSubscriptionActive: true, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: dismissedMessageIds) + matcher = CommonUserAttributeMatcher( + statisticsStore: mockStatisticsStore, + variantManager: manager, + emailManager: emailManager, + bookmarksCount: 44, + favoritesCount: 88, + appTheme: "default", + daysSinceNetPEnabled: 3, + isPrivacyProEligibleUser: true, + isPrivacyProSubscriber: true, + privacyProDaysSinceSubscribed: 5, + privacyProDaysUntilExpiry: 25, + privacyProPurchasePlatform: "apple", + isPrivacyProSubscriptionActive: true, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: dismissedMessageIds + ) } } diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/DeviceAttributeMatcherTests.swift b/Tests/RemoteMessagingTests/Matchers/DeviceAttributeMatcherTests.swift similarity index 98% rename from Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/DeviceAttributeMatcherTests.swift rename to Tests/RemoteMessagingTests/Matchers/DeviceAttributeMatcherTests.swift index ad6192721..d58dae568 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/DeviceAttributeMatcherTests.swift +++ b/Tests/RemoteMessagingTests/Matchers/DeviceAttributeMatcherTests.swift @@ -18,8 +18,8 @@ import XCTest import Foundation -@testable import Common -@testable import BrowserServicesKit +import RemoteMessagingTestsUtils +import Common @testable import RemoteMessaging class DeviceAttributeMatcherTests: XCTestCase { diff --git a/Tests/RemoteMessagingTests/Matchers/MobileUserAttributeMatcherTests.swift b/Tests/RemoteMessagingTests/Matchers/MobileUserAttributeMatcherTests.swift new file mode 100644 index 000000000..23f9ce379 --- /dev/null +++ b/Tests/RemoteMessagingTests/Matchers/MobileUserAttributeMatcherTests.swift @@ -0,0 +1,96 @@ +// +// MobileUserAttributeMatcherTests.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 BrowserServicesKitTestsUtils +import Foundation +import RemoteMessagingTestsUtils +import XCTest +@testable import RemoteMessaging + +class MobileUserAttributeMatcherTests: XCTestCase { + + var mockStatisticsStore: MockStatisticsStore! + var manager: MockVariantManager! + var emailManager: EmailManager! + var matcher: MobileUserAttributeMatcher! + var dateYesterday: Date! + + override func setUpWithError() throws { + let now = Calendar.current.dateComponents(in: .current, from: Date()) + let yesterday = DateComponents(year: now.year, month: now.month, day: now.day! - 1) + let dateYesterday = Calendar.current.date(from: yesterday)! + + mockStatisticsStore = MockStatisticsStore() + mockStatisticsStore.atb = "v105-2" + mockStatisticsStore.appRetentionAtb = "v105-44" + mockStatisticsStore.searchRetentionAtb = "v105-88" + mockStatisticsStore.installDate = dateYesterday + + manager = MockVariantManager(isSupportedReturns: true, + currentVariant: MockVariant(name: "zo", weight: 44, isIncluded: { return true }, features: [.dummy])) + let emailManagerStorage = MockEmailManagerStorage() + + // EmailEnabledMatchingAttribute isSignedIn = true + emailManagerStorage.mockUsername = "username" + emailManagerStorage.mockToken = "token" + + emailManager = EmailManager(storage: emailManagerStorage) + setUpUserAttributeMatcher() + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + + matcher = nil + } + + // MARK: - WidgetAdded + + func testWhenWidgetAddedMatchesThenReturnMatch() throws { + XCTAssertEqual(matcher.evaluate(matchingAttribute: WidgetAddedMatchingAttribute(value: true, fallback: nil)), + .match) + } + + func testWhenWidgetAddedDoesNotMatchThenReturnFail() throws { + XCTAssertEqual(matcher.evaluate(matchingAttribute: WidgetAddedMatchingAttribute(value: false, fallback: nil)), + .fail) + } + + private func setUpUserAttributeMatcher(dismissedMessageIds: [String] = []) { + matcher = MobileUserAttributeMatcher( + statisticsStore: mockStatisticsStore, + variantManager: manager, + emailManager: emailManager, + bookmarksCount: 44, + favoritesCount: 88, + appTheme: "default", + isWidgetInstalled: true, + daysSinceNetPEnabled: 3, + isPrivacyProEligibleUser: true, + isPrivacyProSubscriber: true, + privacyProDaysSinceSubscribed: 5, + privacyProDaysUntilExpiry: 25, + privacyProPurchasePlatform: "apple", + isPrivacyProSubscriptionActive: true, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: dismissedMessageIds + ) + } +} diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Model/RangeStringMatchingAttributeTests.swift b/Tests/RemoteMessagingTests/Model/RangeStringMatchingAttributeTests.swift similarity index 98% rename from Tests/BrowserServicesKitTests/RemoteMessaging/Model/RangeStringMatchingAttributeTests.swift rename to Tests/RemoteMessagingTests/Model/RangeStringMatchingAttributeTests.swift index 9699f0e2c..a7fd73109 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Model/RangeStringMatchingAttributeTests.swift +++ b/Tests/RemoteMessagingTests/Model/RangeStringMatchingAttributeTests.swift @@ -16,9 +16,8 @@ // limitations under the License. // -import XCTest import Foundation -@testable import BrowserServicesKit +import XCTest @testable import RemoteMessaging class RangeStringMatchingAttributeTests: XCTestCase { diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigMatcherTests.swift b/Tests/RemoteMessagingTests/RemoteMessagingConfigMatcherTests.swift similarity index 60% rename from Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigMatcherTests.swift rename to Tests/RemoteMessagingTests/RemoteMessagingConfigMatcherTests.swift index 6baaa09c0..980f9160b 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigMatcherTests.swift +++ b/Tests/RemoteMessagingTests/RemoteMessagingConfigMatcherTests.swift @@ -16,14 +16,15 @@ // limitations under the License. // +import BrowserServicesKit +import Common +import BrowserServicesKitTestsUtils +import RemoteMessagingTestsUtils import XCTest -@testable import Common -@testable import BrowserServicesKit @testable import RemoteMessaging class RemoteMessagingConfigMatcherTests: XCTestCase { - private var data = JsonTestDataLoader() private var matcher: RemoteMessagingConfigMatcher! override func setUpWithError() throws { @@ -36,23 +37,25 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { let emailManager = EmailManager(storage: emailManagerStorage) matcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - emailManager: emailManager, - bookmarksCount: 10, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + emailManager: emailManager, + bookmarksCount: 10, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), percentileStore: MockRemoteMessagePercentileStore(), surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: [] @@ -130,22 +133,24 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { matcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), deviceAttributeMatcher: DeviceAttributeMatcher(osVersion: AppVersion.shared.osVersion, locale: "en-US"), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - bookmarksCount: 0, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + bookmarksCount: 0, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), percentileStore: MockRemoteMessagePercentileStore(), surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) @@ -233,22 +238,24 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { func testWhenUserDismissedMessagesAndDeviceMatchesMultipleMessagesThenReturnFirstMatchNotDismissed() { matcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - bookmarksCount: 10, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + bookmarksCount: 10, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), percentileStore: MockRemoteMessagePercentileStore(), surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: ["1"]) @@ -279,22 +286,24 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { matcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), deviceAttributeMatcher: DeviceAttributeMatcher(osVersion: AppVersion.shared.osVersion, locale: "en-US"), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - bookmarksCount: 0, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + bookmarksCount: 0, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), percentileStore: MockRemoteMessagePercentileStore(), surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) @@ -321,22 +330,24 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { matcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), deviceAttributeMatcher: DeviceAttributeMatcher(osVersion: AppVersion.shared.osVersion, locale: "en-US"), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - bookmarksCount: 0, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + bookmarksCount: 0, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), percentileStore: percentileStore, surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) @@ -361,22 +372,24 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { matcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), deviceAttributeMatcher: DeviceAttributeMatcher(osVersion: AppVersion.shared.osVersion, locale: "en-US"), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - bookmarksCount: 0, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + bookmarksCount: 0, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), percentileStore: percentileStore, surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) @@ -401,22 +414,24 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { matcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), deviceAttributeMatcher: DeviceAttributeMatcher(osVersion: AppVersion.shared.osVersion, locale: "en-US"), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - bookmarksCount: 0, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + bookmarksCount: 0, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), percentileStore: percentileStore, surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) @@ -441,22 +456,24 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { matcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), deviceAttributeMatcher: DeviceAttributeMatcher(osVersion: AppVersion.shared.osVersion, locale: "en-US"), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - bookmarksCount: 0, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + bookmarksCount: 0, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), percentileStore: percentileStore, surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) @@ -503,15 +520,4 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { exclusionRules: exclusionRules ) } - - func decodeAndMapJson(fileName: String) throws -> RemoteConfigModel { - let validJson = data.fromJsonFile(fileName) - let remoteMessagingConfig = try JSONDecoder().decode(RemoteMessageResponse.JsonRemoteMessagingConfig.self, from: validJson) - let surveyMapper = MockRemoteMessageSurveyActionMapper() - XCTAssertNotNil(remoteMessagingConfig) - - let config = JsonToRemoteConfigModelMapper.mapJson(remoteMessagingConfig: remoteMessagingConfig, surveyActionMapper: surveyMapper) - XCTAssertNotNil(config) - return config - } } diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigProcessorTests.swift b/Tests/RemoteMessagingTests/RemoteMessagingConfigProcessorTests.swift similarity index 53% rename from Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigProcessorTests.swift rename to Tests/RemoteMessagingTests/RemoteMessagingConfigProcessorTests.swift index 7b909443a..9f9b33ad8 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigProcessorTests.swift +++ b/Tests/RemoteMessagingTests/RemoteMessagingConfigProcessorTests.swift @@ -16,36 +16,37 @@ // limitations under the License. // +import BrowserServicesKitTestsUtils +import RemoteMessagingTestsUtils import XCTest -@testable import BrowserServicesKit @testable import RemoteMessaging class RemoteMessagingConfigProcessorTests: XCTestCase { - private var data = JsonTestDataLoader() - func testWhenNewVersionThenShouldHaveBeenProcessedAndResultReturned() throws { - let jsonRemoteMessagingConfig = try decodeJson(fileName: "Resources/remote-messaging-config.json") + let jsonRemoteMessagingConfig = try decodeJson(fileName: "remote-messaging-config.json") XCTAssertNotNil(jsonRemoteMessagingConfig) let remoteMessagingConfigMatcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - bookmarksCount: 0, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + bookmarksCount: 0, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), percentileStore: MockRemoteMessagePercentileStore(), surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: [] @@ -63,27 +64,29 @@ class RemoteMessagingConfigProcessorTests: XCTestCase { } func testWhenSameVersionThenNotProcessedAndResultNil() throws { - let jsonRemoteMessagingConfig = try decodeJson(fileName: "Resources/remote-messaging-config-malformed.json") + let jsonRemoteMessagingConfig = try decodeJson(fileName: "remote-messaging-config-malformed.json") XCTAssertNotNil(jsonRemoteMessagingConfig) let remoteMessagingConfigMatcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - bookmarksCount: 0, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + bookmarksCount: 0, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), percentileStore: MockRemoteMessagePercentileStore(), surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) @@ -98,7 +101,8 @@ class RemoteMessagingConfigProcessorTests: XCTestCase { } func decodeJson(fileName: String) throws -> RemoteMessageResponse.JsonRemoteMessagingConfig { - let validJson = data.fromJsonFile(fileName) + let resourceURL = Bundle.module.resourceURL!.appendingPathComponent(fileName, conformingTo: .json) + let validJson = try Data(contentsOf: resourceURL) let remoteMessagingConfig = try JSONDecoder().decode(RemoteMessageResponse.JsonRemoteMessagingConfig.self, from: validJson) XCTAssertNotNil(remoteMessagingConfig) diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingPercentileUserDefaultsStoreTests.swift b/Tests/RemoteMessagingTests/RemoteMessagingPercentileUserDefaultsStoreTests.swift similarity index 74% rename from Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingPercentileUserDefaultsStoreTests.swift rename to Tests/RemoteMessagingTests/RemoteMessagingPercentileUserDefaultsStoreTests.swift index 6e993abb8..b50b6364c 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingPercentileUserDefaultsStoreTests.swift +++ b/Tests/RemoteMessagingTests/RemoteMessagingPercentileUserDefaultsStoreTests.swift @@ -16,22 +16,21 @@ // limitations under the License. // +import TestUtils import XCTest -@testable import BrowserServicesKit @testable import RemoteMessaging class RemoteMessagingPercentileUserDefaultsStoreTests: XCTestCase { - private var userDefaults: UserDefaults! + private var keyValueStore: MockKeyValueStore! override func setUp() { super.setUp() - userDefaults = UserDefaults(suiteName: #file) - userDefaults.removePersistentDomain(forName: #file) + keyValueStore = MockKeyValueStore() } func testWhenFetchingPercentileForFirstTime_ThenPercentileIsCreatedAndStored() { - let store = RemoteMessagingPercentileUserDefaultsStore(userDefaults: userDefaults) + let store = RemoteMessagingPercentileUserDefaultsStore(keyValueStore: keyValueStore) let percentile = store.percentile(forMessageId: "message-1") XCTAssert(percentile >= 0.0) @@ -39,7 +38,7 @@ class RemoteMessagingPercentileUserDefaultsStoreTests: XCTestCase { } func testWhenFetchingPercentileMultipleTimes_ThenAllPercentileFetchesReturnSameValue() { - let store = RemoteMessagingPercentileUserDefaultsStore(userDefaults: userDefaults) + let store = RemoteMessagingPercentileUserDefaultsStore(keyValueStore: keyValueStore) let percentile1 = store.percentile(forMessageId: "message-1") let percentile2 = store.percentile(forMessageId: "message-1") let percentile3 = store.percentile(forMessageId: "message-1") @@ -49,14 +48,14 @@ class RemoteMessagingPercentileUserDefaultsStoreTests: XCTestCase { } func testWhenFetchingPercentileForMultipleMessages_ThenEachMessageHasIndependentPercentile() { - let store = RemoteMessagingPercentileUserDefaultsStore(userDefaults: userDefaults) + let store = RemoteMessagingPercentileUserDefaultsStore(keyValueStore: keyValueStore) _ = store.percentile(forMessageId: "message-1") _ = store.percentile(forMessageId: "message-2") _ = store.percentile(forMessageId: "message-3") - let percentileDictionary = userDefaults.dictionary( - forKey: RemoteMessagingPercentileUserDefaultsStore.Constants.remoteMessagingPercentileMapping - ) + let key = RemoteMessagingPercentileUserDefaultsStore.Constants.remoteMessagingPercentileMapping + + let percentileDictionary = keyValueStore.object(forKey: key) as? [String: Float] XCTAssertEqual(percentileDictionary?.count, 3) } diff --git a/Tests/RemoteMessagingTests/RemoteMessagingProcessingTests.swift b/Tests/RemoteMessagingTests/RemoteMessagingProcessingTests.swift new file mode 100644 index 000000000..5099cc497 --- /dev/null +++ b/Tests/RemoteMessagingTests/RemoteMessagingProcessingTests.swift @@ -0,0 +1,150 @@ +// +// RemoteMessagingProcessingTests.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 BrowserServicesKitTestsUtils +import RemoteMessagingTestsUtils +import XCTest +@testable import RemoteMessaging + +struct TestRemoteMessagingProcessor: RemoteMessagingProcessing { + var endpoint: URL + var configFetcher: RemoteMessagingConfigFetching + var configMatcherProvider: RemoteMessagingConfigMatcherProviding + var remoteMessagingAvailabilityProvider: RemoteMessagingAvailabilityProviding + var remoteMessagingConfigProcessor: RemoteMessagingConfigProcessing + + init( + endpoint: URL = URL(string: "https://example.com/config.json")!, + configFetcher: RemoteMessagingConfigFetching, + configMatcherProvider: RemoteMessagingConfigMatcherProviding, + remoteMessagingAvailabilityProvider: RemoteMessagingAvailabilityProviding, + remoteMessagingConfigProcessor: RemoteMessagingConfigProcessing + ) { + self.endpoint = endpoint + self.configFetcher = configFetcher + self.configMatcherProvider = configMatcherProvider + self.remoteMessagingAvailabilityProvider = remoteMessagingAvailabilityProvider + self.remoteMessagingConfigProcessor = remoteMessagingConfigProcessor + } + + func configProcessor(for configMatcher: RemoteMessagingConfigMatcher) -> RemoteMessagingConfigProcessing { + remoteMessagingConfigProcessor + } +} + +class RemoteMessagingProcessingTests: XCTestCase { + + var availabilityProvider: MockRemoteMessagingAvailabilityProvider! + var configFetcher: MockRemoteMessagingConfigFetcher! + var configProcessor: MockRemoteMessagingConfigProcessor! + var store: MockRemoteMessagingStore! + + var processor: TestRemoteMessagingProcessor! + + override func setUpWithError() throws { + let emailManagerStorage = MockEmailManagerStorage() + + availabilityProvider = MockRemoteMessagingAvailabilityProvider() + + let matcher = RemoteMessagingConfigMatcher( + appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + emailManager: EmailManager(storage: emailManagerStorage), + bookmarksCount: 10, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), + percentileStore: MockRemoteMessagePercentileStore(), + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), + dismissedMessageIds: [] + ) + + configFetcher = MockRemoteMessagingConfigFetcher() + store = MockRemoteMessagingStore() + + configProcessor = MockRemoteMessagingConfigProcessor(remoteMessagingConfigMatcher: matcher) + + processor = TestRemoteMessagingProcessor( + configFetcher: configFetcher, + configMatcherProvider: MockRemoteMessagingConfigMatcherProvider { _ in matcher }, + remoteMessagingAvailabilityProvider: availabilityProvider, + remoteMessagingConfigProcessor: configProcessor + ) + } + + func testWhenFeatureFlagIsDisabledThenProcessingIsSkipped() async throws { + availabilityProvider.isRemoteMessagingAvailable = false + + do { + try await processor.fetchAndProcess(using: store) + } catch { + XCTFail("Unexpected error thrown: \(error).") + } + XCTAssertEqual(store.saveProcessedResultCalls, 0) + } + + func testWhenConfigProcessorReturnsNilThenResultIsNotSaved() async throws { + configProcessor.processConfig = { _, _ in nil } + + do { + try await processor.fetchAndProcess(using: store) + } catch { + XCTFail("Unexpected error thrown: \(error).") + } + XCTAssertEqual(store.saveProcessedResultCalls, 0) + } + + func testWhenConfigProcessorReturnsMessageThenResultIsSaved() async throws { + configProcessor.processConfig = { _, _ in .init(version: 0, message: nil) } + + do { + try await processor.fetchAndProcess(using: store) + } catch { + XCTFail("Unexpected error thrown: \(error).") + } + XCTAssertEqual(store.saveProcessedResultCalls, 1) + } + + func testWhenFetchingConfigFailsThenErrorIsThrown() async throws { + struct SampleError: Error {} + configFetcher.error = SampleError() + + do { + try await processor.fetchAndProcess(using: store) + XCTFail("Expected to throw error") + } catch { + XCTAssertTrue(error is SampleError) + } + XCTAssertEqual(store.saveProcessedResultCalls, 0) + } + +} diff --git a/Tests/RemoteMessagingTests/RemoteMessagingStoreTests.swift b/Tests/RemoteMessagingTests/RemoteMessagingStoreTests.swift index b63467947..fb2e65fae 100644 --- a/Tests/RemoteMessagingTests/RemoteMessagingStoreTests.swift +++ b/Tests/RemoteMessagingTests/RemoteMessagingStoreTests.swift @@ -16,19 +16,21 @@ // limitations under the License. // +import BrowserServicesKitTestsUtils import CoreData import Foundation import Persistence +import RemoteMessagingTestsUtils +import TestUtils import XCTest @testable import RemoteMessaging class RemoteMessagingStoreTests: XCTestCase { - static let userDefaultsSuiteName = "remote-messaging-store-tests" - - private var store: RemoteMessagingStore! - private let notificationCenter = NotificationCenter() - private var defaults: UserDefaults! + var store: RemoteMessagingStore! + let notificationCenter = NotificationCenter() + var defaults: MockKeyValueStore! + var availabilityProvider: MockRemoteMessagingAvailabilityProvider! var remoteMessagingDatabase: CoreDataDatabase! var location: URL! @@ -43,12 +45,17 @@ class RemoteMessagingStoreTests: XCTestCase { } remoteMessagingDatabase = CoreDataDatabase(name: type(of: self).description(), containerLocation: location, model: model) remoteMessagingDatabase.loadStore() - let context = remoteMessagingDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) - store = RemoteMessagingStore(context: context, notificationCenter: notificationCenter, errorEvents: nil) + availabilityProvider = MockRemoteMessagingAvailabilityProvider() + + store = RemoteMessagingStore( + database: remoteMessagingDatabase, + notificationCenter: notificationCenter, + errorEvents: nil, + remoteMessagingAvailabilityProvider: availabilityProvider + ) - defaults = UserDefaults(suiteName: Self.userDefaultsSuiteName)! - defaults.removePersistentDomain(forName: Self.userDefaultsSuiteName) + defaults = MockKeyValueStore() } override func tearDownWithError() throws { @@ -96,47 +103,169 @@ class RemoteMessagingStoreTests: XCTestCase { func testWhenHasNotShownMessageThenReturnFalse() throws { let remoteMessage = try saveProcessedResultFetchRemoteMessage() - XCTAssertFalse(store.hasShownRemoteMessage(withId: remoteMessage.id)) + XCTAssertFalse(store.hasShownRemoteMessage(withID: remoteMessage.id)) } func testWhenUpdateRemoteMessageAsShownMessageThenHasShownIsTrue() throws { let remoteMessage = try saveProcessedResultFetchRemoteMessage() - store.updateRemoteMessage(withId: remoteMessage.id, asShown: true) - XCTAssertTrue(store.hasShownRemoteMessage(withId: remoteMessage.id)) + store.updateRemoteMessage(withID: remoteMessage.id, asShown: true) + XCTAssertTrue(store.hasShownRemoteMessage(withID: remoteMessage.id)) } func testWhenUpdateRemoteMessageAsShownFalseThenHasShownIsFalse() throws { let remoteMessage = try saveProcessedResultFetchRemoteMessage() - store.updateRemoteMessage(withId: remoteMessage.id, asShown: false) - XCTAssertFalse(store.hasShownRemoteMessage(withId: remoteMessage.id)) + store.updateRemoteMessage(withID: remoteMessage.id, asShown: false) + XCTAssertFalse(store.hasShownRemoteMessage(withID: remoteMessage.id)) } func testWhenDismissRemoteMessageThenFetchedMessageHasDismissedState() throws { let remoteMessage = try saveProcessedResultFetchRemoteMessage() - store.dismissRemoteMessage(withId: remoteMessage.id) + store.dismissRemoteMessage(withID: remoteMessage.id) - guard let fetchedRemoteMessage = store.fetchRemoteMessage(withId: remoteMessage.id) else { + guard let fetchedRemoteMessage = store.fetchRemoteMessage(withID: remoteMessage.id) else { XCTFail("No remote message found") return } XCTAssertEqual(fetchedRemoteMessage.id, remoteMessage.id) - XCTAssertTrue(store.hasDismissedRemoteMessage(withId: fetchedRemoteMessage.id)) + XCTAssertTrue(store.hasDismissedRemoteMessage(withID: fetchedRemoteMessage.id)) } func testFetchDismissedRemoteMessageIds() throws { let remoteMessage = try saveProcessedResultFetchRemoteMessage() - store.dismissRemoteMessage(withId: remoteMessage.id) + store.dismissRemoteMessage(withID: remoteMessage.id) - let dismissedRemoteMessageIds = store.fetchDismissedRemoteMessageIds() + let dismissedRemoteMessageIds = store.fetchDismissedRemoteMessageIDs() XCTAssertEqual(dismissedRemoteMessageIds.count, 1) XCTAssertEqual(dismissedRemoteMessageIds.first, remoteMessage.id) } + // MARK: - Feature Flag + + func testWhenFeatureFlagIsDisabledThenScheduledRemoteMessagesAreDeleted() throws { + _ = try saveProcessedResultFetchRemoteMessage() + XCTAssertNotNil(store.fetchScheduledRemoteMessage()) + + let expectation = XCTNSNotificationExpectation(name: RemoteMessagingStore.Notifications.remoteMessagesDidChange, + object: nil, notificationCenter: notificationCenter) + + availabilityProvider.isRemoteMessagingAvailable = false + XCTAssertNil(store.fetchScheduledRemoteMessage()) + + wait(for: [expectation], timeout: 1) + + // Re-enabling remote messaging doesn't trigger a refetch on a Store level so no new scheduled messages should appear + availabilityProvider.isRemoteMessagingAvailable = true + XCTAssertNil(store.fetchScheduledRemoteMessage()) + } + + func testWhenFeatureFlagIsDisabledAndThereWereNoMessagesThenNotificationIsNotSent() throws { + XCTAssertNil(store.fetchScheduledRemoteMessage()) + + let expectation = XCTNSNotificationExpectation(name: RemoteMessagingStore.Notifications.remoteMessagesDidChange, + object: nil, notificationCenter: notificationCenter) + expectation.isInverted = true + + availabilityProvider.isRemoteMessagingAvailable = false + XCTAssertNil(store.fetchScheduledRemoteMessage()) + + wait(for: [expectation], timeout: 1) + } + + func testWhenFeatureFlagIsDisabledAndThereWereNoScheduledMessagesThenNotificationIsNotSent() throws { + _ = try saveProcessedResultFetchRemoteMessage() + + // Dismiss all available messages + while let remoteMessage = store.fetchScheduledRemoteMessage() { + store.dismissRemoteMessage(withID: remoteMessage.id) + } + + let expectation = XCTNSNotificationExpectation(name: RemoteMessagingStore.Notifications.remoteMessagesDidChange, + object: nil, notificationCenter: notificationCenter) + expectation.isInverted = true + + availabilityProvider.isRemoteMessagingAvailable = false + XCTAssertNil(store.fetchScheduledRemoteMessage()) + + wait(for: [expectation], timeout: 1) + } + + func testWhenFeatureFlagIsDisabledThenProcessedResultIsNotSaved() throws { + availabilityProvider.isRemoteMessagingAvailable = false + + let expectation = XCTNSNotificationExpectation(name: RemoteMessagingStore.Notifications.remoteMessagesDidChange, + object: nil, notificationCenter: notificationCenter) + expectation.isInverted = true + + let processorResult = try processorResult() + store.saveProcessedResult(processorResult) + + wait(for: [expectation], timeout: 1) + } + + func testWhenFeatureFlagIsDisabledThenFetchScheduledRemoteMessageReturnsNil() throws { + _ = try saveProcessedResultFetchRemoteMessage() + availabilityProvider.isRemoteMessagingAvailable = false + + XCTAssertNil(store.fetchScheduledRemoteMessage()) + } + + func testWhenFeatureFlagIsDisabledThenFetchRemoteMessagingConfigReturnsNil() throws { + _ = try saveProcessedResultFetchRemoteMessage() + availabilityProvider.isRemoteMessagingAvailable = false + + XCTAssertNil(store.fetchRemoteMessagingConfig()) + } + + func testWhenFeatureFlagIsDisabledThenFetchedMessageReturnsNil() throws { + let remoteMessage = try saveProcessedResultFetchRemoteMessage() + + availabilityProvider.isRemoteMessagingAvailable = false + + XCTAssertNil(store.fetchRemoteMessage(withID: remoteMessage.id)) + } + + func testWhenFeatureFlagIsDisabledThenUpdateShownFlagHasNoEffect() throws { + let remoteMessage = try saveProcessedResultFetchRemoteMessage() + availabilityProvider.isRemoteMessagingAvailable = false + + store.updateRemoteMessage(withID: remoteMessage.id, asShown: true) + + XCTAssertFalse(store.hasShownRemoteMessage(withID: remoteMessage.id)) + } + + func testWhenFeatureFlagIsDisabledThenHasShownMessageReturnFalse() throws { + let remoteMessage = try saveProcessedResultFetchRemoteMessage() + store.updateRemoteMessage(withID: remoteMessage.id, asShown: true) + availabilityProvider.isRemoteMessagingAvailable = false + + XCTAssertFalse(store.hasShownRemoteMessage(withID: remoteMessage.id)) + } + + func testWhenFeatureFlagIsDisabledThenDismissingRemoteMessageHasNoEffect() throws { + let remoteMessage = try saveProcessedResultFetchRemoteMessage() + + availabilityProvider.isRemoteMessagingAvailable = false + store.dismissRemoteMessage(withID: remoteMessage.id) + + XCTAssertEqual(store.fetchDismissedRemoteMessageIDs(), []) + } + + func testWhenFeatureFlagIsDisabledThenHasDismissedRemoteMessageReturnsFalse() throws { + let remoteMessage = try saveProcessedResultFetchRemoteMessage() + + store.dismissRemoteMessage(withID: remoteMessage.id) + availabilityProvider.isRemoteMessagingAvailable = false + + XCTAssertEqual(store.hasDismissedRemoteMessage(withID: remoteMessage.id), false) + } + + // MARK: - + func decodeJson(fileName: String) throws -> RemoteMessageResponse.JsonRemoteMessagingConfig { - let resourceURL = Bundle.module.resourceURL!.appendingPathComponent("remote-messaging-config-example.json", conformingTo: .json) + let resourceURL = Bundle.module.resourceURL!.appendingPathComponent(fileName, conformingTo: .json) let validJson = try Data(contentsOf: resourceURL) let remoteMessagingConfig = try JSONDecoder().decode(RemoteMessageResponse.JsonRemoteMessagingConfig.self, from: validJson) @@ -149,23 +278,25 @@ class RemoteMessagingStoreTests: XCTestCase { let jsonRemoteMessagingConfig = try decodeJson(fileName: "remote-messaging-config-example.json") let remoteMessagingConfigMatcher = RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: MockStatisticsStore(), variantManager: MockVariantManager()), - userAttributeMatcher: UserAttributeMatcher(statisticsStore: MockStatisticsStore(), - variantManager: MockVariantManager(), - bookmarksCount: 0, - favoritesCount: 0, - appTheme: "light", - isWidgetInstalled: false, - daysSinceNetPEnabled: -1, - isPrivacyProEligibleUser: false, - isPrivacyProSubscriber: false, - privacyProDaysSinceSubscribed: -1, - privacyProDaysUntilExpiry: -1, - privacyProPurchasePlatform: nil, - isPrivacyProSubscriptionActive: false, - isPrivacyProSubscriptionExpiring: false, - isPrivacyProSubscriptionExpired: false, - dismissedMessageIds: []), - percentileStore: RemoteMessagingPercentileUserDefaultsStore(userDefaults: self.defaults), + userAttributeMatcher: MobileUserAttributeMatcher( + statisticsStore: MockStatisticsStore(), + variantManager: MockVariantManager(), + bookmarksCount: 0, + favoritesCount: 0, + appTheme: "light", + isWidgetInstalled: false, + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false, + privacyProDaysSinceSubscribed: -1, + privacyProDaysUntilExpiry: -1, + privacyProPurchasePlatform: nil, + isPrivacyProSubscriptionActive: false, + isPrivacyProSubscriptionExpiring: false, + isPrivacyProSubscriptionExpired: false, + dismissedMessageIds: [] + ), + percentileStore: RemoteMessagingPercentileUserDefaultsStore(keyValueStore: self.defaults), surveyActionMapper: MockRemoteMessagingSurveyActionMapper(), dismissedMessageIds: [] ) diff --git a/Tests/BrowserServicesKitTests/Resources/remote-messaging-config-malformed.json b/Tests/RemoteMessagingTests/Resources/remote-messaging-config-malformed.json similarity index 100% rename from Tests/BrowserServicesKitTests/Resources/remote-messaging-config-malformed.json rename to Tests/RemoteMessagingTests/Resources/remote-messaging-config-malformed.json diff --git a/Tests/BrowserServicesKitTests/Resources/remote-messaging-config-unsupported-items.json b/Tests/RemoteMessagingTests/Resources/remote-messaging-config-unsupported-items.json similarity index 100% rename from Tests/BrowserServicesKitTests/Resources/remote-messaging-config-unsupported-items.json rename to Tests/RemoteMessagingTests/Resources/remote-messaging-config-unsupported-items.json diff --git a/Tests/BrowserServicesKitTests/Resources/remote-messaging-config.json b/Tests/RemoteMessagingTests/Resources/remote-messaging-config.json similarity index 100% rename from Tests/BrowserServicesKitTests/Resources/remote-messaging-config.json rename to Tests/RemoteMessagingTests/Resources/remote-messaging-config.json