diff --git a/Sources/Bookmarks/BookmarkEditorViewModel.swift b/Sources/Bookmarks/BookmarkEditorViewModel.swift index b7d8cb1ea..c680380b3 100644 --- a/Sources/Bookmarks/BookmarkEditorViewModel.swift +++ b/Sources/Bookmarks/BookmarkEditorViewModel.swift @@ -31,11 +31,15 @@ public class BookmarkEditorViewModel: ObservableObject { } let context: NSManagedObjectContext + public let favoritesDisplayMode: FavoritesDisplayMode @Published public var bookmark: BookmarkEntity @Published public var locations = [Location]() - lazy var favoritesFolder: BookmarkEntity! = BookmarkUtils.fetchFavoritesFolder(context) + lazy var favoritesFolder: BookmarkEntity! = BookmarkUtils.fetchFavoritesFolder( + withUUID: favoritesDisplayMode.displayedFolder.rawValue, + in: context + ) private var observer: NSObjectProtocol? private let subject = PassthroughSubject() @@ -59,11 +63,13 @@ public class BookmarkEditorViewModel: ObservableObject { public init(editingEntityID: NSManagedObjectID, bookmarksDatabase: CoreDataDatabase, + favoritesDisplayMode: FavoritesDisplayMode, errorEvents: EventMapping?) { externalUpdates = subject.eraseToAnyPublisher() self.errorEvents = errorEvents self.context = bookmarksDatabase.makeContext(concurrencyType: .mainQueueConcurrencyType) + self.favoritesDisplayMode = favoritesDisplayMode guard let entity = context.object(with: editingEntityID) as? BookmarkEntity else { // For sync, this is valid scenario in case of a timing issue @@ -84,11 +90,13 @@ public class BookmarkEditorViewModel: ObservableObject { public init(creatingFolderWithParentID parentFolderID: NSManagedObjectID?, bookmarksDatabase: CoreDataDatabase, + favoritesDisplayMode: FavoritesDisplayMode, errorEvents: EventMapping?) { externalUpdates = subject.eraseToAnyPublisher() self.errorEvents = errorEvents self.context = bookmarksDatabase.makeContext(concurrencyType: .mainQueueConcurrencyType) + self.favoritesDisplayMode = favoritesDisplayMode let parent: BookmarkEntity? if let parentFolderID = parentFolderID { @@ -173,13 +181,13 @@ public class BookmarkEditorViewModel: ObservableObject { } public func removeFromFavorites() { - assert(bookmark.isFavorite) - bookmark.removeFromFavorites() + assert(bookmark.isFavorite(on: favoritesDisplayMode.displayedFolder)) + bookmark.removeFromFavorites(with: favoritesDisplayMode) } public func addToFavorites() { - assert(!bookmark.isFavorite) - bookmark.addToFavorites(favoritesRoot: favoritesFolder) + assert(!bookmark.isFavorite(on: favoritesDisplayMode.displayedFolder)) + bookmark.addToFavorites(with: favoritesDisplayMode, in: context) } public func setParentWithID(_ parentID: NSManagedObjectID) { diff --git a/Sources/Bookmarks/BookmarkEntity.swift b/Sources/Bookmarks/BookmarkEntity.swift index f73d03592..26b16a11a 100644 --- a/Sources/Bookmarks/BookmarkEntity.swift +++ b/Sources/Bookmarks/BookmarkEntity.swift @@ -21,12 +21,28 @@ import Foundation import CoreData +/** + * This enum defines available favorites folders with their UUIDs as raw value. + */ +public enum FavoritesFolderID: String, CaseIterable { + /// Mobile form factor favorites folder + case mobile = "mobile_favorites_root" + /// Desktop form factor favorites folder + case desktop = "desktop_favorites_root" + /// Unified (mobile + desktop) favorites folder + case unified = "favorites_root" +} + @objc(BookmarkEntity) public class BookmarkEntity: NSManagedObject { public enum Constants { public static let rootFolderID = "bookmarks_root" - public static let favoritesFolderID = "favorites_root" + public static let favoriteFoldersIDs: Set = Set(FavoritesFolderID.allCases.map(\.rawValue)) + } + + public static func isValidFavoritesFolderID(_ value: String) -> Bool { + FavoritesFolderID.allCases.contains { $0.rawValue == value } } public enum Error: Swift.Error { @@ -49,7 +65,7 @@ public class BookmarkEntity: NSManagedObject { @NSManaged public var url: String? @NSManaged public var uuid: String? @NSManaged public var children: NSOrderedSet? - @NSManaged fileprivate(set) public var favoriteFolder: BookmarkEntity? + @NSManaged public fileprivate(set) var favoriteFolders: NSSet? @NSManaged public fileprivate(set) var favorites: NSOrderedSet? @NSManaged public var parent: BookmarkEntity? @@ -58,8 +74,12 @@ public class BookmarkEntity: NSManagedObject { /// In-memory flag. When set to `false`, disables adjusting `modifiedAt` on `willSave()`. It's reset to `true` on `didSave()`. public var shouldManageModifiedAt: Bool = true - public var isFavorite: Bool { - favoriteFolder != nil + public func isFavorite(on platform: FavoritesFolderID) -> Bool { + favoriteFoldersSet.contains { $0.uuid == platform.rawValue } + } + + public var favoritedOn: [FavoritesFolderID] { + favoriteFoldersSet.compactMap(\.uuid).compactMap(FavoritesFolderID.init) } public convenience init(context moc: NSManagedObjectContext) { @@ -81,7 +101,7 @@ public class BookmarkEntity: NSManagedObject { guard !changedKeys.isEmpty, !changedKeys.contains(NSStringFromSelector(#selector(getter: modifiedAt))) else { return } - if isInserted && (uuid == Constants.rootFolderID || uuid == Constants.favoritesFolderID) { + if isInserted, let uuid, uuid == Constants.rootFolderID || Self.isValidFavoritesFolderID(uuid) { return } modifiedAt = Date() @@ -123,6 +143,10 @@ public class BookmarkEntity: NSManagedObject { return children.filter { $0.isPendingDeletion == false } } + public var favoriteFoldersSet: Set { + return favoriteFolders.flatMap(Set.init) ?? [] + } + public static func makeFolder(title: String, parent: BookmarkEntity, insertAtBeginning: Bool = false, @@ -168,9 +192,21 @@ public class BookmarkEntity: NSManagedObject { root.addToFavorites(self) } } - - public func removeFromFavorites() { - favoriteFolder = nil + + public func addToFavorites(folders: [BookmarkEntity]) { + for root in folders { + root.addToFavorites(self) + } + } + + public func removeFromFavorites(folders: [BookmarkEntity]) { + for root in folders { + root.removeFromFavorites(self) + } + } + + public func removeFromFavorites(favoritesRoot: BookmarkEntity) { + favoritesRoot.removeFromFavorites(self) } public func markPendingDeletion() { @@ -200,20 +236,12 @@ extension BookmarkEntity { func validate() throws { try validateThatFoldersDoNotHaveURLs() try validateThatFolderHierarchyHasNoCycles() - try validateFavoritesStatus() try validateFavoritesFolder() } - func validateFavoritesStatus() throws { - let isInFavoriteCollection = favoriteFolder != nil - if isFavorite != isInFavoriteCollection { - throw Error.invalidFavoritesStatus - } - } - func validateFavoritesFolder() throws { - if let favoritesFolderID = favoriteFolder?.uuid, - favoritesFolderID != Constants.favoritesFolderID { + let uuids = Set(favoriteFoldersSet.compactMap(\.uuid)) + guard uuids.isSubset(of: Constants.favoriteFoldersIDs) else { throw Error.invalidFavoritesFolder } } @@ -296,6 +324,23 @@ extension BookmarkEntity { } +// MARK: Generated accessors for favoriteFolders +extension BookmarkEntity { + + @objc(addFavoriteFoldersObject:) + @NSManaged private func addToFavoriteFolders(_ value: BookmarkEntity) + + @objc(removeFavoriteFoldersObject:) + @NSManaged private func removeFromFavoriteFolders(_ value: BookmarkEntity) + + @objc(addFavoriteFolders:) + @NSManaged private func addToFavoriteFolders(_ values: NSSet) + + @objc(removeFavoriteFolders:) + @NSManaged private func removeFromFavoriteFolders(_ values: NSSet) + +} + extension BookmarkEntity: Identifiable { } diff --git a/Sources/Bookmarks/BookmarkListViewModel.swift b/Sources/Bookmarks/BookmarkListViewModel.swift index c5f8452bb..a2bb9a3d2 100644 --- a/Sources/Bookmarks/BookmarkListViewModel.swift +++ b/Sources/Bookmarks/BookmarkListViewModel.swift @@ -27,6 +27,11 @@ public class BookmarkListViewModel: BookmarkListInteracting, ObservableObject { public let currentFolder: BookmarkEntity? let context: NSManagedObjectContext + public var favoritesDisplayMode: FavoritesDisplayMode { + didSet { + reloadData() + } + } public var bookmarks = [BookmarkEntity]() @@ -40,11 +45,13 @@ public class BookmarkListViewModel: BookmarkListInteracting, ObservableObject { public init(bookmarksDatabase: CoreDataDatabase, parentID: NSManagedObjectID?, + favoritesDisplayMode: FavoritesDisplayMode, errorEvents: EventMapping?) { self.externalUpdates = self.subject.eraseToAnyPublisher() self.localUpdates = self.localSubject.eraseToAnyPublisher() self.errorEvents = errorEvents self.context = bookmarksDatabase.makeContext(concurrencyType: .mainQueueConcurrencyType) + self.favoritesDisplayMode = favoritesDisplayMode if let parentID = parentID { if let bookmark = (try? context.existingObject(with: parentID)) as? BookmarkEntity { @@ -75,19 +82,19 @@ public class BookmarkListViewModel: BookmarkListInteracting, ObservableObject { } } - // swiftlint:disable:next function_parameter_count - public func createBookmark(title: String, - url: String, - folder: BookmarkEntity, - folderIndex: Int, - favoritesFolder: BookmarkEntity?, - favoritesIndex: Int?) { + public func createBookmark( + title: String, + url: String, + folder: BookmarkEntity, + folderIndex: Int, + favoritesFoldersAndIndexes: [BookmarkEntity: Int] + ) { let bookmark = BookmarkEntity.makeBookmark(title: title, url: url, parent: folder, context: context) if let addedIndex = folder.childrenArray.firstIndex(of: bookmark) { moveBookmark(bookmark, fromIndex: addedIndex, toIndex: folderIndex) } - if let favoritesFolder, let favoritesIndex { - bookmark.addToFavorites(insertAt: favoritesIndex, favoritesRoot: favoritesFolder) + for (favoritesFolder, index) in favoritesFoldersAndIndexes { + bookmark.addToFavorites(insertAt: index, favoritesRoot: favoritesFolder) } save() } @@ -115,10 +122,10 @@ public class BookmarkListViewModel: BookmarkListInteracting, ObservableObject { } public func toggleFavorite(_ bookmark: BookmarkEntity) { - if bookmark.isFavorite { - bookmark.removeFromFavorites() - } else if let folder = BookmarkUtils.fetchFavoritesFolder(context) { - bookmark.addToFavorites(favoritesRoot: folder) + if bookmark.isFavorite(on: favoritesDisplayMode.displayedFolder) { + bookmark.removeFromFavorites(with: favoritesDisplayMode) + } else { + bookmark.addToFavorites(with: favoritesDisplayMode, in: context) } save() } diff --git a/Sources/Bookmarks/BookmarkUtils.swift b/Sources/Bookmarks/BookmarkUtils.swift index 4d47b2bc2..236b0f2c5 100644 --- a/Sources/Bookmarks/BookmarkUtils.swift +++ b/Sources/Bookmarks/BookmarkUtils.swift @@ -30,21 +30,38 @@ public struct BookmarkUtils { return try? context.fetch(request).first } - public static func fetchFavoritesFolder(_ context: NSManagedObjectContext) -> BookmarkEntity? { + public static func fetchFavoritesFolders(for displayMode: FavoritesDisplayMode, in context: NSManagedObjectContext) -> [BookmarkEntity] { + fetchFavoritesFolders(withUUIDs: displayMode.folderUUIDs, in: context) + } + + public static func fetchFavoritesFolder(withUUID uuid: String, in context: NSManagedObjectContext) -> BookmarkEntity? { + assert(BookmarkEntity.isValidFavoritesFolderID(uuid)) + let request = BookmarkEntity.fetchRequest() - request.predicate = NSPredicate(format: "%K == %@", #keyPath(BookmarkEntity.uuid), BookmarkEntity.Constants.favoritesFolderID) + request.predicate = NSPredicate(format: "%K == %@", #keyPath(BookmarkEntity.uuid), uuid) request.returnsObjectsAsFaults = false request.fetchLimit = 1 - + return try? context.fetch(request).first } + public static func fetchFavoritesFolders(withUUIDs uuids: Set, in context: NSManagedObjectContext) -> [BookmarkEntity] { + assert(uuids.allSatisfy { BookmarkEntity.isValidFavoritesFolderID($0) }) + + let request = BookmarkEntity.fetchRequest() + request.predicate = NSPredicate(format: "%K in %@", #keyPath(BookmarkEntity.uuid), uuids) + request.returnsObjectsAsFaults = false + request.fetchLimit = uuids.count + + return (try? context.fetch(request)) ?? [] + } + public static func fetchOrphanedEntities(_ context: NSManagedObjectContext) -> [BookmarkEntity] { let request = BookmarkEntity.fetchRequest() request.predicate = NSPredicate( format: "NOT %K IN %@ AND %K == NO AND %K == nil", #keyPath(BookmarkEntity.uuid), - [BookmarkEntity.Constants.rootFolderID, BookmarkEntity.Constants.favoritesFolderID], + BookmarkEntity.Constants.favoriteFoldersIDs.union([BookmarkEntity.Constants.rootFolderID]), #keyPath(BookmarkEntity.isPendingDeletion), #keyPath(BookmarkEntity.parent) ) @@ -55,24 +72,81 @@ public struct BookmarkUtils { } public static func prepareFoldersStructure(in context: NSManagedObjectContext) { - - func insertRootFolder(uuid: String, into context: NSManagedObjectContext) { - let folder = BookmarkEntity(entity: BookmarkEntity.entity(in: context), - insertInto: context) - folder.uuid = uuid - folder.title = uuid - folder.isFolder = true - } - + if fetchRootFolder(context) == nil { insertRootFolder(uuid: BookmarkEntity.Constants.rootFolderID, into: context) } - - if fetchFavoritesFolder(context) == nil { - insertRootFolder(uuid: BookmarkEntity.Constants.favoritesFolderID, into: context) + + for uuid in BookmarkEntity.Constants.favoriteFoldersIDs where fetchFavoritesFolder(withUUID: uuid, in: context) == nil { + insertRootFolder(uuid: uuid, into: context) + } + } + + public static func migrateToFormFactorSpecificFavorites(byCopyingExistingTo folderID: FavoritesFolderID, in context: NSManagedObjectContext) { + assert(folderID != .unified, "You must specify either desktop or mobile folder") + + guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.unified.rawValue, in: context) else { + return + } + + if BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.desktop.rawValue, in: context) == nil { + let desktopFavoritesFolder = insertRootFolder(uuid: FavoritesFolderID.desktop.rawValue, into: context) + + if folderID == .desktop { + favoritesFolder.favoritesArray.forEach { bookmark in + bookmark.addToFavorites(favoritesRoot: desktopFavoritesFolder) + } + } else { + desktopFavoritesFolder.shouldManageModifiedAt = false + } + } + + if BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.mobile.rawValue, in: context) == nil { + let mobileFavoritesFolder = insertRootFolder(uuid: FavoritesFolderID.mobile.rawValue, into: context) + + if folderID == .mobile { + favoritesFolder.favoritesArray.forEach { bookmark in + bookmark.addToFavorites(favoritesRoot: mobileFavoritesFolder) + } + } else { + mobileFavoritesFolder.shouldManageModifiedAt = false + } + } + } + + public static func copyFavorites( + from sourceFolderID: FavoritesFolderID, + to targetFolderID: FavoritesFolderID, + clearingNonNativeFavoritesFolder nonNativeFolderID: FavoritesFolderID, + in context: NSManagedObjectContext + ) { + assert(nonNativeFolderID != .unified, "You must specify either desktop or mobile folder") + assert(Set([sourceFolderID, targetFolderID, nonNativeFolderID]).count == 3, "You must pass 3 different folder IDs to this function") + assert([sourceFolderID, targetFolderID].contains(FavoritesFolderID.unified), "You must copy to or from a unified folder") + + let allFavoritesFolders = BookmarkUtils.fetchFavoritesFolders(withUUIDs: Set(FavoritesFolderID.allCases.map(\.rawValue)), in: context) + assert(allFavoritesFolders.count == FavoritesFolderID.allCases.count, "Favorites folders missing") + + guard let sourceFavoritesFolder = allFavoritesFolders.first(where: { $0.uuid == sourceFolderID.rawValue }), + let targetFavoritesFolder = allFavoritesFolders.first(where: { $0.uuid == targetFolderID.rawValue }), + let nonNativeFormFactorFavoritesFolder = allFavoritesFolders.first(where: { $0.uuid == nonNativeFolderID.rawValue }) + else { + return + } + + nonNativeFormFactorFavoritesFolder.favoritesArray.forEach { bookmark in + bookmark.removeFromFavorites(favoritesRoot: nonNativeFormFactorFavoritesFolder) + } + + targetFavoritesFolder.favoritesArray.forEach { bookmark in + bookmark.removeFromFavorites(favoritesRoot: targetFavoritesFolder) + } + + sourceFavoritesFolder.favoritesArray.forEach { bookmark in + bookmark.addToFavorites(favoritesRoot: targetFavoritesFolder) } } - + public static func fetchBookmark(for url: URL, predicate: NSPredicate = NSPredicate(value: true), context: NSManagedObjectContext) -> BookmarkEntity? { @@ -101,4 +175,39 @@ public struct BookmarkUtils { return (try? context.fetch(request)) ?? [] } + + // MARK: Internal + + @discardableResult + static func insertRootFolder(uuid: String, into context: NSManagedObjectContext) -> BookmarkEntity { + let folder = BookmarkEntity(entity: BookmarkEntity.entity(in: context), + insertInto: context) + folder.uuid = uuid + folder.title = uuid + folder.isFolder = true + + return folder + } +} + +// MARK: - Legacy Migration Support + +extension BookmarkUtils { + + public static func prepareLegacyFoldersStructure(in context: NSManagedObjectContext) { + + if fetchRootFolder(context) == nil { + insertRootFolder(uuid: BookmarkEntity.Constants.rootFolderID, into: context) + } + + if fetchLegacyFavoritesFolder(context) == nil { + insertRootFolder(uuid: legacyFavoritesFolderID, into: context) + } + } + + public static func fetchLegacyFavoritesFolder(_ context: NSManagedObjectContext) -> BookmarkEntity? { + fetchFavoritesFolder(withUUID: legacyFavoritesFolderID, in: context) + } + + static let legacyFavoritesFolderID = FavoritesFolderID.unified.rawValue } diff --git a/Sources/Bookmarks/BookmarksModel.swift b/Sources/Bookmarks/BookmarksModel.swift index c83a42931..85084c283 100644 --- a/Sources/Bookmarks/BookmarksModel.swift +++ b/Sources/Bookmarks/BookmarksModel.swift @@ -28,8 +28,10 @@ public protocol BookmarkStoring { func reloadData() } -public protocol BookmarkListInteracting: BookmarkStoring { - +public protocol BookmarkListInteracting: BookmarkStoring, AnyObject { + + var favoritesDisplayMode: FavoritesDisplayMode { get set } + var currentFolder: BookmarkEntity? { get } var bookmarks: [BookmarkEntity] { get } var totalBookmarksCount: Int { get } @@ -48,15 +50,16 @@ public protocol BookmarkListInteracting: BookmarkStoring { func countBookmarksForDomain(_ domain: String) -> Int - // swiftlint:disable:next function_parameter_count - func createBookmark(title: String, url: String, folder: BookmarkEntity, folderIndex: Int, favoritesFolder: BookmarkEntity?, favoritesIndex: Int?) + func createBookmark(title: String, url: String, folder: BookmarkEntity, folderIndex: Int, favoritesFoldersAndIndexes: [BookmarkEntity: Int]) } -public protocol FavoritesListInteracting: BookmarkStoring { - +public protocol FavoritesListInteracting: BookmarkStoring, AnyObject { + + var favoritesDisplayMode: FavoritesDisplayMode { get set } + var favorites: [BookmarkEntity] { get } - + func favorite(at index: Int) -> BookmarkEntity? func removeFavorite(_ favorite: BookmarkEntity) @@ -68,6 +71,8 @@ public protocol FavoritesListInteracting: BookmarkStoring { public protocol MenuBookmarksInteracting { + var favoritesDisplayMode: FavoritesDisplayMode { get set } + func createOrToggleFavorite(title: String, url: URL) func createBookmark(title: String, url: URL) diff --git a/Sources/Bookmarks/BookmarksModel.xcdatamodeld/.xccurrentversion b/Sources/Bookmarks/BookmarksModel.xcdatamodeld/.xccurrentversion index f6574b696..9ebe855d9 100644 --- a/Sources/Bookmarks/BookmarksModel.xcdatamodeld/.xccurrentversion +++ b/Sources/Bookmarks/BookmarksModel.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - BookmarksModel 3.xcdatamodel + BookmarksModel 4.xcdatamodel diff --git a/Sources/Bookmarks/BookmarksModel.xcdatamodeld/BookmarksModel 4.xcdatamodel/contents b/Sources/Bookmarks/BookmarksModel.xcdatamodeld/BookmarksModel 4.xcdatamodel/contents new file mode 100644 index 000000000..539012d01 --- /dev/null +++ b/Sources/Bookmarks/BookmarksModel.xcdatamodeld/BookmarksModel 4.xcdatamodel/contents @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/Bookmarks/FavoriteListViewModel.swift b/Sources/Bookmarks/FavoriteListViewModel.swift index 291135443..f5dfbe33b 100644 --- a/Sources/Bookmarks/FavoriteListViewModel.swift +++ b/Sources/Bookmarks/FavoriteListViewModel.swift @@ -27,6 +27,12 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject let context: NSManagedObjectContext public var favorites = [BookmarkEntity]() + public var favoritesDisplayMode: FavoritesDisplayMode { + didSet { + _favoritesFolder = nil + reloadData() + } + } private var observer: NSObjectProtocol? private let subject = PassthroughSubject() @@ -36,12 +42,28 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject private let errorEvents: EventMapping? - public init(bookmarksDatabase: CoreDataDatabase, - errorEvents: EventMapping?) { + private var _favoritesFolder: BookmarkEntity? + private var favoriteFolder: BookmarkEntity? { + if _favoritesFolder == nil { + _favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: favoritesDisplayMode.displayedFolder.rawValue, in: context) + + if _favoritesFolder == nil { + errorEvents?.fire(.fetchingRootItemFailed(.favorites)) + } + } + return _favoritesFolder + } + + public init( + bookmarksDatabase: CoreDataDatabase, + errorEvents: EventMapping?, + favoritesDisplayMode: FavoritesDisplayMode + ) { self.externalUpdates = self.subject.eraseToAnyPublisher() self.localUpdates = self.localSubject.eraseToAnyPublisher() + self.favoritesDisplayMode = favoritesDisplayMode self.errorEvents = errorEvents - + self.context = bookmarksDatabase.makeContext(concurrencyType: .mainQueueConcurrencyType) refresh() registerForChanges() @@ -78,13 +100,13 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject } private func refresh() { - guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) else { + guard let favoriteFolder else { errorEvents?.fire(.fetchingRootItemFailed(.favorites)) favorites = [] return } - readFavorites(with: favoritesFolder) + readFavorites(with: favoriteFolder) } public func favorite(at index: Int) -> BookmarkEntity? { @@ -97,12 +119,12 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject } public func removeFavorite(_ favorite: BookmarkEntity) { - guard let favoriteFolder = favorite.favoriteFolder else { - errorEvents?.fire(.missingParent(.favorite)) + guard let favoriteFolder else { + errorEvents?.fire(.fetchingRootItemFailed(.favorites)) return } - favorite.removeFromFavorites() + favorite.removeFromFavorites(with: favoritesDisplayMode) save() @@ -112,8 +134,8 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject public func moveFavorite(_ favorite: BookmarkEntity, fromIndex: Int, toIndex: Int) { - guard let favoriteFolder = favorite.favoriteFolder else { - errorEvents?.fire(.missingParent(.favorite)) + guard let favoriteFolder else { + errorEvents?.fire(.fetchingRootItemFailed(.favorites)) return } @@ -160,7 +182,6 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject } private func readFavorites(with favoritesFolder: BookmarkEntity) { - favorites = (favoritesFolder.favorites?.array as? [BookmarkEntity] ?? []) - .filter { !$0.isPendingDeletion } + favorites = favoritesFolder.favoritesArray } } diff --git a/Sources/Bookmarks/FavoritesDisplayMode.swift b/Sources/Bookmarks/FavoritesDisplayMode.swift new file mode 100644 index 000000000..8c1058857 --- /dev/null +++ b/Sources/Bookmarks/FavoritesDisplayMode.swift @@ -0,0 +1,138 @@ +// +// FavoritesDisplayMode.swift +// DuckDuckGo +// +// Copyright © 2023 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 CoreData +import Foundation + +/** + * This enum defines which set of favorites should be displayed to the user. + * + * Users only ever see one set of favorites at a time, and as long as Sync + * is not enabled, it's the one corresponding to the local device (native) + * form factor, i.e. `mobile` on iOS and iPadOS and `desktop` on macOS. + * + * When Sync is enabled, users get to choose between displaying their native + * form factor folder, or a unified folder that contains favorites from + * both mobile and desktop combined. + */ +public enum FavoritesDisplayMode: Equatable { + /** + * Display native form factor favorites. + * + * This case takes a parameter that specifies the native form factor. + * It's up to the client app to define its native form factor. + * + * Using a parameter gives the flexibility of overriding the form factor + * on a given client in the future (e.g. treat `desktop` as native form + * factor on the iPad). + */ + case displayNative(FavoritesFolderID) + + /** + * Display unified favorites (mobile + desktop combined) + * + * This case takes a parameter that specifies the native form factor. + * It's required because all favorites that are added to or deleted from + * the unified folder need also to be added to or deleted from their + * respective native form factor folder. + */ + case displayUnified(native: FavoritesFolderID) + + /// Returns true if the current mode is to display unified folder. + public var isDisplayUnified: Bool { + switch self { + case .displayNative: + return false + case .displayUnified: + return true + } + } + + /// Returns the UUID of a folder that is displayed for a given display mode. + public var displayedFolder: FavoritesFolderID { + switch self { + case .displayNative(let platform): + return platform + case .displayUnified: + return .unified + } + } + + /// Returns the UUID of a native favorites folder for a given display mode. + public var nativeFolder: FavoritesFolderID { + switch self { + case .displayNative(let native), .displayUnified(let native): + return native + } + } + + /// Returns UUIDs of folders that all favorites must be added to in the current display mode. + var folderUUIDs: Set { + [nativeFolder.rawValue, FavoritesFolderID.unified.rawValue] + } +} + +extension FavoritesDisplayMode: CustomStringConvertible { + public var description: String { + switch self { + case .displayNative: + return "display_native" + case .displayUnified: + return "display_all" + } + } +} + +extension BookmarkEntity { + + /** + * Adds sender to favorites according to `displayMode` passed as argument. + */ + public func addToFavorites(with displayMode: FavoritesDisplayMode, in context: NSManagedObjectContext) { + let folders = BookmarkUtils.fetchFavoritesFolders(withUUIDs: displayMode.folderUUIDs, in: context) + addToFavorites(folders: folders) + } + + /** + * Removes sender from favorites according to `displayMode` passed as argument. + * + * When current mode is to display unified favorites - a favorite is removed from all folders. + * When current mode is to display native form factor - it's removed from the native form factor + * folder, and if it's not favorites on non-native form factor then also removed from unified folder. + */ + public func removeFromFavorites(with displayMode: FavoritesDisplayMode) { + let affectedFolders: [BookmarkEntity] = { + let isFavoritedOnlyOnNativeFormFactor = Set(favoriteFoldersSet.compactMap(\.uuid)) == displayMode.folderUUIDs + if displayMode.isDisplayUnified || isFavoritedOnlyOnNativeFormFactor { + return Array(favoriteFoldersSet) + } + if let nativeFolder = favoriteFoldersSet.first(where: { $0.uuid == displayMode.nativeFolder.rawValue }) { + return [nativeFolder] + } + return [] + }() + + assert(!affectedFolders.isEmpty) + + if !affectedFolders.isEmpty { + removeFromFavorites(folders: affectedFolders) + } + } + +} diff --git a/Sources/Bookmarks/ImportExport/BookmarkCoreDataImporter.swift b/Sources/Bookmarks/ImportExport/BookmarkCoreDataImporter.swift index 74ba5eccd..4a9202039 100644 --- a/Sources/Bookmarks/ImportExport/BookmarkCoreDataImporter.swift +++ b/Sources/Bookmarks/ImportExport/BookmarkCoreDataImporter.swift @@ -23,9 +23,11 @@ import Persistence public class BookmarkCoreDataImporter { let context: NSManagedObjectContext + let favoritesDisplayMode: FavoritesDisplayMode - public init(database: CoreDataDatabase) { + public init(database: CoreDataDatabase, favoritesDisplayMode: FavoritesDisplayMode) { self.context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + self.favoritesDisplayMode = favoritesDisplayMode } public func importBookmarks(_ bookmarks: [BookmarkOrFolder]) async throws { @@ -34,8 +36,9 @@ public class BookmarkCoreDataImporter { context.performAndWait { () -> Void in do { - guard let topLevelBookmarksFolder = BookmarkUtils.fetchRootFolder(context), - let topLevelFavoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) else { + let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(for: favoritesDisplayMode, in: context) + + guard let topLevelBookmarksFolder = BookmarkUtils.fetchRootFolder(context) else { throw BookmarksCoreDataError.fetchingExistingItemFailed } @@ -43,7 +46,7 @@ public class BookmarkCoreDataImporter { try recursivelyCreateEntities(from: bookmarks, parent: topLevelBookmarksFolder, - favoritesRoot: topLevelFavoritesFolder, + favoritesFolders: favoritesFolders, bookmarkURLToIDMap: &bookmarkURLToIDMap) try context.save() continuation.resume() @@ -85,7 +88,7 @@ public class BookmarkCoreDataImporter { private func recursivelyCreateEntities(from bookmarks: [BookmarkOrFolder], parent: BookmarkEntity, - favoritesRoot: BookmarkEntity, + favoritesFolders: [BookmarkEntity], bookmarkURLToIDMap: inout [String: NSManagedObjectID]) throws { for bookmarkOrFolder in bookmarks { if bookmarkOrFolder.isInvalidBookmark { @@ -100,20 +103,20 @@ public class BookmarkCoreDataImporter { if let children = bookmarkOrFolder.children { try recursivelyCreateEntities(from: children, parent: folder, - favoritesRoot: favoritesRoot, + favoritesFolders: favoritesFolders, bookmarkURLToIDMap: &bookmarkURLToIDMap) } case .favorite: if let url = bookmarkOrFolder.url { if let objectID = bookmarkURLToIDMap[url.absoluteString], let bookmark = try? context.existingObject(with: objectID) as? BookmarkEntity { - bookmark.addToFavorites(favoritesRoot: favoritesRoot) + bookmark.addToFavorites(folders: favoritesFolders) } else { let newFavorite = BookmarkEntity.makeBookmark(title: bookmarkOrFolder.name, url: url.absoluteString, parent: parent, context: context) - newFavorite.addToFavorites(favoritesRoot: favoritesRoot) + newFavorite.addToFavorites(folders: favoritesFolders) bookmarkURLToIDMap[url.absoluteString] = newFavorite.objectID } } diff --git a/Sources/Bookmarks/MenuBookmarksViewModel.swift b/Sources/Bookmarks/MenuBookmarksViewModel.swift index a36ad8621..edb25fcad 100644 --- a/Sources/Bookmarks/MenuBookmarksViewModel.swift +++ b/Sources/Bookmarks/MenuBookmarksViewModel.swift @@ -24,7 +24,12 @@ import Persistence public class MenuBookmarksViewModel: MenuBookmarksInteracting { let context: NSManagedObjectContext - + public var favoritesDisplayMode: FavoritesDisplayMode = .displayNative(.mobile) { + didSet { + _favoritesFolder = nil + } + } + private var _rootFolder: BookmarkEntity? private var rootFolder: BookmarkEntity? { if _rootFolder == nil { @@ -40,7 +45,7 @@ public class MenuBookmarksViewModel: MenuBookmarksInteracting { private var _favoritesFolder: BookmarkEntity? private var favoritesFolder: BookmarkEntity? { if _favoritesFolder == nil { - _favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) + _favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: favoritesDisplayMode.displayedFolder.rawValue, in: context) if _favoritesFolder == nil { errorEvents?.fire(.fetchingRootItemFailed(.menu)) @@ -53,8 +58,7 @@ public class MenuBookmarksViewModel: MenuBookmarksInteracting { private let errorEvents: EventMapping? - public init(bookmarksDatabase: CoreDataDatabase, - errorEvents: EventMapping?) { + public init(bookmarksDatabase: CoreDataDatabase, errorEvents: EventMapping?) { self.errorEvents = errorEvents self.context = bookmarksDatabase.makeContext(concurrencyType: .mainQueueConcurrencyType) registerForChanges() @@ -92,25 +96,24 @@ public class MenuBookmarksViewModel: MenuBookmarksInteracting { } public func createOrToggleFavorite(title: String, url: URL) { - guard let favoritesFolder = favoritesFolder, - let rootFolder = rootFolder else { + guard let rootFolder = rootFolder else { return } let queriedBookmark = favorite(for: url) ?? bookmark(for: url) if let bookmark = queriedBookmark { - if bookmark.isFavorite { - bookmark.removeFromFavorites() + if bookmark.isFavorite(on: favoritesDisplayMode.displayedFolder) { + bookmark.removeFromFavorites(with: favoritesDisplayMode) } else { - bookmark.addToFavorites(favoritesRoot: favoritesFolder) + bookmark.addToFavorites(with: favoritesDisplayMode, in: context) } } else { let favorite = BookmarkEntity.makeBookmark(title: title, url: url.absoluteString, parent: rootFolder, context: context) - favorite.addToFavorites(favoritesRoot: favoritesFolder) + favorite.addToFavorites(with: favoritesDisplayMode, in: context) } save() @@ -128,10 +131,14 @@ public class MenuBookmarksViewModel: MenuBookmarksInteracting { } public func favorite(for url: URL) -> BookmarkEntity? { - BookmarkUtils.fetchBookmark(for: url, + guard let favoritesFolder else { + return nil + } + return BookmarkUtils.fetchBookmark(for: url, predicate: NSPredicate( - format: "%K != nil AND %K == NO", - #keyPath(BookmarkEntity.favoriteFolder), + format: "ANY %K CONTAINS %@ AND %K == NO", + #keyPath(BookmarkEntity.favoriteFolders), + favoritesFolder, #keyPath(BookmarkEntity.isPendingDeletion) ), context: context) diff --git a/Sources/BookmarksTestsUtils/BookmarkTree.swift b/Sources/BookmarksTestsUtils/BookmarkTree.swift index 50d3526d4..e50347c4d 100644 --- a/Sources/BookmarksTestsUtils/BookmarkTree.swift +++ b/Sources/BookmarksTestsUtils/BookmarkTree.swift @@ -60,7 +60,7 @@ public struct ModifiedAtConstraint { } public enum BookmarkTreeNode { - case bookmark(id: String, name: String?, url: String?, isFavorite: Bool, modifiedAt: Date?, isDeleted: Bool, isOrphaned: Bool, modifiedAtConstraint: ModifiedAtConstraint?) + case bookmark(id: String, name: String?, url: String?, favoritedOn: [FavoritesFolderID], modifiedAt: Date?, isDeleted: Bool, isOrphaned: Bool, modifiedAtConstraint: ModifiedAtConstraint?) case folder(id: String, name: String?, children: [BookmarkTreeNode], modifiedAt: Date?, isDeleted: Bool, isOrphaned: Bool, modifiedAtConstraint: ModifiedAtConstraint?) public var id: String { @@ -126,17 +126,17 @@ public struct Bookmark: BookmarkTreeNodeConvertible { var id: String var name: String? var url: String? - var isFavorite: Bool + var favoritedOn: [FavoritesFolderID] var modifiedAt: Date? var isDeleted: Bool var isOrphaned: Bool var modifiedAtConstraint: ModifiedAtConstraint? - public init(_ name: String? = nil, id: String? = nil, url: String? = nil, isFavorite: Bool = false, modifiedAt: Date? = nil, isDeleted: Bool = false, isOrphaned: Bool = false, modifiedAtConstraint: ModifiedAtConstraint? = nil) { + public init(_ name: String? = nil, id: String? = nil, url: String? = nil, favoritedOn: [FavoritesFolderID] = [], modifiedAt: Date? = nil, isDeleted: Bool = false, isOrphaned: Bool = false, modifiedAtConstraint: ModifiedAtConstraint? = nil) { self.id = id ?? UUID().uuidString self.name = name ?? id self.url = (url ?? name) ?? id - self.isFavorite = isFavorite + self.favoritedOn = favoritedOn self.modifiedAt = modifiedAt self.isDeleted = isDeleted self.modifiedAtConstraint = modifiedAtConstraint @@ -144,7 +144,7 @@ public struct Bookmark: BookmarkTreeNodeConvertible { } public func asBookmarkTreeNode() -> BookmarkTreeNode { - .bookmark(id: id, name: name, url: url, isFavorite: isFavorite, modifiedAt: modifiedAt, isDeleted: isDeleted, isOrphaned: isOrphaned, modifiedAtConstraint: modifiedAtConstraint) + .bookmark(id: id, name: name, url: url, favoritedOn: favoritedOn, modifiedAt: modifiedAt, isDeleted: isDeleted, isOrphaned: isOrphaned, modifiedAtConstraint: modifiedAtConstraint) } } @@ -203,14 +203,14 @@ public struct BookmarkTree { public func createEntitiesForCheckingModifiedAt(in context: NSManagedObjectContext) -> (BookmarkEntity, [BookmarkEntity], [String: ModifiedAtConstraint]) { let rootFolder = BookmarkUtils.fetchRootFolder(context)! rootFolder.modifiedAt = modifiedAt - let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context)! + let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(withUUIDs: Set(FavoritesFolderID.allCases.map(\.rawValue)), in: context) var orphans = [BookmarkEntity]() var modifiedAtConstraints = [String: ModifiedAtConstraint]() if let modifiedAtConstraint { modifiedAtConstraints[BookmarkEntity.Constants.rootFolderID] = modifiedAtConstraint } for bookmarkTreeNode in bookmarkTreeNodes { - let (entity, checks) = BookmarkEntity.makeWithModifiedAtConstraints(with: bookmarkTreeNode, rootFolder: rootFolder, favoritesFolder: favoritesFolder, in: context) + let (entity, checks) = BookmarkEntity.makeWithModifiedAtConstraints(with: bookmarkTreeNode, rootFolder: rootFolder, favoritesFolders: favoritesFolders, in: context) if bookmarkTreeNode.isOrphaned { orphans.append(entity) } @@ -230,12 +230,12 @@ public struct BookmarkTree { public extension BookmarkEntity { @discardableResult - static func make(with treeNode: BookmarkTreeNode, rootFolder: BookmarkEntity, favoritesFolder: BookmarkEntity, in context: NSManagedObjectContext) -> BookmarkEntity { - makeWithModifiedAtConstraints(with: treeNode, rootFolder: rootFolder, favoritesFolder: favoritesFolder, in: context).0 + static func make(with treeNode: BookmarkTreeNode, rootFolder: BookmarkEntity, favoritesFolders: [BookmarkEntity], in context: NSManagedObjectContext) -> BookmarkEntity { + makeWithModifiedAtConstraints(with: treeNode, rootFolder: rootFolder, favoritesFolders: favoritesFolders, in: context).0 } @discardableResult - static func makeWithModifiedAtConstraints(with treeNode: BookmarkTreeNode, rootFolder: BookmarkEntity, favoritesFolder: BookmarkEntity, in context: NSManagedObjectContext) -> (BookmarkEntity, [String: ModifiedAtConstraint]) { + static func makeWithModifiedAtConstraints(with treeNode: BookmarkTreeNode, rootFolder: BookmarkEntity, favoritesFolders: [BookmarkEntity], in context: NSManagedObjectContext) -> (BookmarkEntity, [String: ModifiedAtConstraint]) { var entity: BookmarkEntity! var queues: [[BookmarkTreeNode]] = [[treeNode]] @@ -250,7 +250,7 @@ public extension BookmarkEntity { let node = queue.removeFirst() switch node { - case .bookmark(let id, let name, let url, let isFavorite, let modifiedAt, let isDeleted, let isOrphaned, let modifiedAtConstraint): + case .bookmark(let id, let name, let url, let favoritedOn, let modifiedAt, let isDeleted, let isOrphaned, let modifiedAtConstraint): let bookmarkEntity = BookmarkEntity(context: context) if entity == nil { entity = bookmarkEntity @@ -261,9 +261,13 @@ public extension BookmarkEntity { bookmarkEntity.url = url bookmarkEntity.modifiedAt = modifiedAt modifiedAtConstraints[id] = modifiedAtConstraint - if isFavorite { - bookmarkEntity.addToFavorites(favoritesRoot: favoritesFolder) + + for platform in favoritedOn { + if let favoritesFolder = favoritesFolders.first(where: { $0.uuid == platform.rawValue }) { + bookmarkEntity.addToFavorites(favoritesRoot: favoritesFolder) + } } + if isDeleted { bookmarkEntity.markPendingDeletion() } @@ -341,7 +345,7 @@ public extension XCTestCase { XCTAssertEqual(expectedNode.isFolder, thisNode.isFolder, "isFolder mismatch for \(thisUUID)", file: file, line: line) XCTAssertEqual(expectedNode.isPendingDeletion, thisNode.isPendingDeletion, "isPendingDeletion mismatch for \(thisUUID)", file: file, line: line) XCTAssertEqual(expectedNode.children?.count, thisNode.children?.count, "children count mismatch for \(thisUUID)", file: file, line: line) - XCTAssertEqual(expectedNode.isFavorite, thisNode.isFavorite, "isFavorite mismatch for \(thisUUID)", file: file, line: line) + XCTAssertEqual(Set(expectedNode.favoritedOn), Set(thisNode.favoritedOn), "favoritedOn mismatch for \(thisUUID)", file: file, line: line) if withTimestamps { if let modifiedAtConstraint = modifiedAtConstraints[thisUUID] { modifiedAtConstraint.check(thisNode.modifiedAt) diff --git a/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift b/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift index 5c69cb8c8..ce4353f3c 100644 --- a/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift +++ b/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift @@ -23,9 +23,6 @@ import DDGSync import Foundation final class BookmarksResponseHandler { - // Before form-factor-specific favorites is supported, we deliberately ignore FFS folders. - static let ignoredFoldersUUIDs: Set = ["desktop_favorites_root", "mobile_favorites_root"] - let clientTimestamp: Date? let received: [SyncableBookmarkAdapter] let context: NSManagedObjectContext @@ -36,7 +33,7 @@ final class BookmarksResponseHandler { let topLevelFoldersSyncables: [SyncableBookmarkAdapter] let bookmarkSyncablesWithoutParent: [SyncableBookmarkAdapter] - let favoritesUUIDs: [String]? + let favoritesUUIDsByFolderUUID: [String: [String]] var entitiesByUUID: [String: BookmarkEntity] = [:] var idsOfItemsThatRetainModifiedAt = Set() @@ -57,7 +54,7 @@ final class BookmarksResponseHandler { var allUUIDs: Set = [] var childrenToParents: [String: String] = [:] var parentFoldersToChildren: [String: [String]] = [:] - var favoritesUUIDs: [String]? + var favoritesUUIDsByFolderUUID: [String: [String]] = [:] self.received.forEach { syncable in guard let uuid = syncable.uuid else { @@ -70,8 +67,8 @@ final class BookmarksResponseHandler { allUUIDs.formUnion(syncable.children) } - if uuid == BookmarkEntity.Constants.favoritesFolderID { - favoritesUUIDs = syncable.children + if BookmarkEntity.isValidFavoritesFolderID(uuid) { + favoritesUUIDsByFolderUUID[uuid] = syncable.children } else { if syncable.isFolder { parentFoldersToChildren[uuid] = syncable.children @@ -84,14 +81,13 @@ final class BookmarksResponseHandler { self.allReceivedIDs = allUUIDs self.receivedByUUID = syncablesByUUID - self.favoritesUUIDs = favoritesUUIDs + self.favoritesUUIDsByFolderUUID = favoritesUUIDsByFolderUUID let foldersWithoutParent = Set(parentFoldersToChildren.keys).subtracting(childrenToParents.keys) - .subtracting(Self.ignoredFoldersUUIDs) topLevelFoldersSyncables = foldersWithoutParent.compactMap { syncablesByUUID[$0] } bookmarkSyncablesWithoutParent = allUUIDs.subtracting(childrenToParents.keys) - .subtracting(foldersWithoutParent + [BookmarkEntity.Constants.favoritesFolderID] + Self.ignoredFoldersUUIDs) + .subtracting(foldersWithoutParent.union(BookmarkEntity.Constants.favoriteFoldersIDs)) .compactMap { syncablesByUUID[$0] } BookmarkEntity.fetchBookmarks(with: allReceivedIDs, in: context) @@ -119,28 +115,35 @@ final class BookmarksResponseHandler { // MARK: - Private private func processReceivedFavorites() { - guard let favoritesUUIDs else { - return - } + for (favoritesFolderUUID, favoritesUUIDs) in favoritesUUIDsByFolderUUID { + guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: favoritesFolderUUID, in: context) else { + // Error - unable to process favorites + return + } - guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) else { - // Error - unable to process favorites - return - } +// guard let favoritesUUIDs else { +// return +// } +// +// guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) else { +// // Error - unable to process favorites +// return +// } - // For non-first sync we rely fully on the server response - if !shouldDeduplicateEntities { - favoritesFolder.favoritesArray.forEach { $0.removeFromFavorites() } - } else if !favoritesFolder.favoritesArray.isEmpty { - // If we're deduplicating and there are favorites locally, we'll need to sync favorites folder back later. - // Let's keep its modifiedAt. - idsOfItemsThatRetainModifiedAt.insert(BookmarkEntity.Constants.favoritesFolderID) - } + // For non-first sync we rely fully on the server response + if !shouldDeduplicateEntities { + favoritesFolder.favoritesArray.forEach { $0.removeFromFavorites(favoritesRoot: favoritesFolder) } + } else if !favoritesFolder.favoritesArray.isEmpty { + // If we're deduplicating and there are favorites locally, we'll need to sync favorites folder back later. + // Let's keep its modifiedAt. + idsOfItemsThatRetainModifiedAt.insert(favoritesFolderUUID) + } - favoritesUUIDs.forEach { uuid in - if let bookmark = entitiesByUUID[uuid] { - bookmark.removeFromFavorites() - bookmark.addToFavorites(favoritesRoot: favoritesFolder) + favoritesUUIDs.forEach { uuid in + if let bookmark = entitiesByUUID[uuid] { + bookmark.removeFromFavorites(favoritesRoot: favoritesFolder) + bookmark.addToFavorites(favoritesRoot: favoritesFolder) + } } } } diff --git a/Sources/SyncDataProviders/Bookmarks/internal/ReceivedBookmarksIndex.swift b/Sources/SyncDataProviders/Bookmarks/internal/ReceivedBookmarksIndex.swift deleted file mode 100644 index 727b05bca..000000000 --- a/Sources/SyncDataProviders/Bookmarks/internal/ReceivedBookmarksIndex.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// ReceivedBookmarksIndex.swift -// DuckDuckGo -// -// Copyright © 2023 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 Bookmarks -import CoreData -import DDGSync -import Foundation - -struct ReceivedBookmarksIndex { - let receivedByUUID: [String: SyncableBookmarkAdapter] - let allReceivedIDs: Set - - let topLevelFoldersSyncables: [SyncableBookmarkAdapter] - let bookmarkSyncablesWithoutParent: [SyncableBookmarkAdapter] - let favoritesUUIDs: [String] - - var entitiesByUUID: [String: BookmarkEntity] = [:] - - init(received: [SyncableBookmarkAdapter], in context: NSManagedObjectContext) { - var syncablesByUUID: [String: SyncableBookmarkAdapter] = [:] - var allUUIDs: Set = [] - var childrenToParents: [String: String] = [:] - var parentFoldersToChildren: [String: [String]] = [:] - var favoritesUUIDs: [String] = [] - - received.forEach { syncable in - guard let uuid = syncable.uuid else { - return - } - syncablesByUUID[uuid] = syncable - - allUUIDs.insert(uuid) - if syncable.isFolder { - allUUIDs.formUnion(syncable.children) - } - - if uuid == BookmarkEntity.Constants.favoritesFolderID { - favoritesUUIDs = syncable.children - } else { - if syncable.isFolder { - parentFoldersToChildren[uuid] = syncable.children - } - syncable.children.forEach { child in - childrenToParents[child] = uuid - } - } - } - - self.allReceivedIDs = allUUIDs - self.receivedByUUID = syncablesByUUID - self.favoritesUUIDs = favoritesUUIDs - - let foldersWithoutParent = Set(parentFoldersToChildren.keys).subtracting(childrenToParents.keys) - topLevelFoldersSyncables = foldersWithoutParent.compactMap { syncablesByUUID[$0] } - - bookmarkSyncablesWithoutParent = allUUIDs.subtracting(childrenToParents.keys) - .subtracting(foldersWithoutParent + [BookmarkEntity.Constants.favoritesFolderID]) - .compactMap { syncablesByUUID[$0] } - - BookmarkEntity.fetchBookmarks(with: allReceivedIDs, in: context) - .forEach { bookmark in - guard let uuid = bookmark.uuid else { - return - } - entitiesByUUID[uuid] = bookmark - } - } -} diff --git a/Sources/SyncDataProviders/Bookmarks/internal/SyncableBookmarkAdapter.swift b/Sources/SyncDataProviders/Bookmarks/internal/SyncableBookmarkAdapter.swift index 5619ed41e..40a1df9ff 100644 --- a/Sources/SyncDataProviders/Bookmarks/internal/SyncableBookmarkAdapter.swift +++ b/Sources/SyncDataProviders/Bookmarks/internal/SyncableBookmarkAdapter.swift @@ -79,7 +79,7 @@ extension Syncable { payload["title"] = try encrypt(title) } if bookmark.isFolder { - if bookmark.uuid == BookmarkEntity.Constants.favoritesFolderID { + if BookmarkEntity.Constants.favoriteFoldersIDs.contains(uuid) { payload["folder"] = [ "children": bookmark.favoritesArray.map(\.uuid) ] diff --git a/Sources/SyncDataProviders/Settings/SettingsProvider.swift b/Sources/SyncDataProviders/Settings/SettingsProvider.swift index c6cf2cccd..fc59bb1fd 100644 --- a/Sources/SyncDataProviders/Settings/SettingsProvider.swift +++ b/Sources/SyncDataProviders/Settings/SettingsProvider.swift @@ -27,7 +27,7 @@ import Persistence /** * Error that may occur while updating timestamp when a setting changes. * - * This error should be published via `SettingsSyncHandling.errorPublisher` + * This error should be published via `SettingSyncHandling.errorPublisher` * whenever settings metadata database fails to save changes after updating * timestamp for a given setting. * @@ -42,42 +42,50 @@ public struct SettingsSyncMetadataSaveError: Error { } // swiftlint:disable:next type_body_length -public final class SettingsProvider: DataProvider, SettingsSyncHandlingDelegate { +public final class SettingsProvider: DataProvider, SettingSyncHandlingDelegate { public struct Setting: Hashable { - let key: String + public let key: String + + public init(key: String) { + self.key = key + } } public convenience init( metadataDatabase: CoreDataDatabase, metadataStore: SyncMetadataStore, - emailManager: EmailManagerSyncSupporting, + settingsHandlers: [SettingSyncHandler], syncDidUpdateData: @escaping () -> Void ) { - let emailProtectionSyncHandler = EmailProtectionSyncHandler(emailManager: emailManager) + let settingsHandlersBySetting = settingsHandlers.reduce(into: [Setting: any SettingSyncHandling]()) { partialResult, handler in + partialResult[handler.setting] = handler + } + + let settingsHandlers = settingsHandlersBySetting self.init( metadataDatabase: metadataDatabase, metadataStore: metadataStore, - settingsHandlers: [ - .emailProtectionGeneration: emailProtectionSyncHandler - ], + settingsHandlersBySetting: settingsHandlers, syncDidUpdateData: syncDidUpdateData ) register(errorPublisher: errorSubject.eraseToAnyPublisher()) - emailProtectionSyncHandler.delegate = self + settingsHandlers.values.forEach { handler in + handler.delegate = self + } } init( metadataDatabase: CoreDataDatabase, metadataStore: SyncMetadataStore, - settingsHandlers: [Setting: any SettingsSyncHandling], + settingsHandlersBySetting: [Setting: any SettingSyncHandling], syncDidUpdateData: @escaping () -> Void ) { self.metadataDatabase = metadataDatabase - self.settingsHandlers = settingsHandlers + self.settingsHandlers = settingsHandlersBySetting super.init(feature: .init(name: "settings"), metadataStore: metadataStore, syncDidUpdateData: syncDidUpdateData) } @@ -295,7 +303,7 @@ public final class SettingsProvider: DataProvider, SettingsSyncHandlingDelegate return idsOfItemsToClearModifiedAt } - func syncHandlerDidUpdateSettingValue(_ handler: SettingsSyncHandling) { + func syncHandlerDidUpdateSettingValue(_ handler: SettingSyncHandling) { updateMetadataTimestamp(for: handler.setting) } @@ -332,7 +340,7 @@ public final class SettingsProvider: DataProvider, SettingsSyncHandlingDelegate } private let metadataDatabase: CoreDataDatabase - private let settingsHandlers: [Setting: any SettingsSyncHandling] + private let settingsHandlers: [Setting: any SettingSyncHandling] private let errorSubject = PassthroughSubject() enum Const { diff --git a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift index e0bd5838e..861ffc3d1 100644 --- a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift +++ b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift @@ -37,17 +37,18 @@ extension SettingsProvider.Setting { static let emailProtectionGeneration = SettingsProvider.Setting(key: "email_protection_generation") } -class EmailProtectionSyncHandler: SettingsSyncHandling { +public final class EmailProtectionSyncHandler: SettingSyncHandler { struct Payload: Codable { let username: String let personalAccessToken: String } - let setting: SettingsProvider.Setting = .emailProtectionGeneration - weak var delegate: SettingsSyncHandlingDelegate? + public override var setting: SettingsProvider.Setting { + .emailProtectionGeneration + } - func getValue() throws -> String? { + public override func getValue() throws -> String? { guard let user = try emailManager.getUsername() else { return nil } @@ -58,7 +59,7 @@ class EmailProtectionSyncHandler: SettingsSyncHandling { return String(bytes: data, encoding: .utf8) } - func setValue(_ value: String?) throws { + public override func setValue(_ value: String?) throws { guard let value, let valueData = value.data(using: .utf8) else { try emailManager.signOut(isForced: false) return @@ -68,19 +69,14 @@ class EmailProtectionSyncHandler: SettingsSyncHandling { try emailManager.signIn(username: payload.username, token: payload.personalAccessToken) } - init(emailManager: EmailManagerSyncSupporting) { - self.emailManager = emailManager + public override var valueDidChangePublisher: AnyPublisher { + emailManager.userDidToggleEmailProtectionPublisher + } - emailProtectionStatusDidChangeCancellable = self.emailManager.userDidToggleEmailProtectionPublisher - .sink { [weak self] in - guard let self else { - return - } - assert(self.delegate != nil, "delegate has not been set for \(type(of: self))") - self.delegate?.syncHandlerDidUpdateSettingValue(self) - } + public init(emailManager: EmailManagerSyncSupporting) { + self.emailManager = emailManager + super.init() } private let emailManager: EmailManagerSyncSupporting - private var emailProtectionStatusDidChangeCancellable: AnyCancellable? } diff --git a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/FavoritesDisplayModeSyncHandlerBase.swift b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/FavoritesDisplayModeSyncHandlerBase.swift new file mode 100644 index 000000000..0414e1da3 --- /dev/null +++ b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/FavoritesDisplayModeSyncHandlerBase.swift @@ -0,0 +1,32 @@ +// +// FavoritesDisplayModeSyncHandlerBase.swift +// DuckDuckGo +// +// Copyright © 2023 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 Bookmarks +import Foundation + +extension SettingsProvider.Setting { + static let favoritesDisplayMode = SettingsProvider.Setting(key: "favorites_display_mode") +} + +open class FavoritesDisplayModeSyncHandlerBase: SettingSyncHandler { + + open override var setting: SettingsProvider.Setting { + .favoritesDisplayMode + } +} diff --git a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandler.swift b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandler.swift new file mode 100644 index 000000000..643699ec9 --- /dev/null +++ b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandler.swift @@ -0,0 +1,57 @@ +// +// SettingSyncHandler.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +open class SettingSyncHandler: SettingSyncHandling { + + open var setting: SettingsProvider.Setting { + assertionFailure("implementation missing for \(#function)") + return .init(key: "") + } + + open var valueDidChangePublisher: AnyPublisher { + assertionFailure("implementation missing for \(#function)") + return Empty().eraseToAnyPublisher() + } + + open func getValue() throws -> String? { + assertionFailure("implementation missing for \(#function)") + return nil + } + + open func setValue(_ value: String?) throws { + assertionFailure("implementation missing for \(#function)") + } + + public init() { + valueDidChangeCancellable = valueDidChangePublisher + .sink { [weak self] in + guard let self else { + return + } + assert(self.delegate != nil, "delegate has not been set for \(type(of: self))") + self.delegate?.syncHandlerDidUpdateSettingValue(self) + } + } + + weak var delegate: SettingSyncHandlingDelegate? + private var valueDidChangeCancellable: AnyCancellable? +} diff --git a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingsSyncHandling.swift b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandling.swift similarity index 84% rename from Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingsSyncHandling.swift rename to Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandling.swift index fb0174a16..5f4dbc8b0 100644 --- a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingsSyncHandling.swift +++ b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandling.swift @@ -1,5 +1,5 @@ // -// SettingsSyncHandling.swift +// SettingSyncHandling.swift // DuckDuckGo // // Copyright © 2023 DuckDuckGo. All rights reserved. @@ -32,7 +32,9 @@ import Persistence * * fine-tune Sync behavior for the setting, * * track changes to setting's value and notify delegate accordingly. */ -protocol SettingsSyncHandling { +protocol SettingSyncHandling: AnyObject { + + var valueDidChangePublisher: AnyPublisher { get } /** * Returns setting identifier that this handler supports. * @@ -59,19 +61,19 @@ protocol SettingsSyncHandling { * * The delegate must be set, otherwise an assertion failure is called. */ - var delegate: SettingsSyncHandlingDelegate? { get set } + var delegate: SettingSyncHandlingDelegate? { get set } } /** - * Protocol defining delegate interface for Settings Sync Handler. + * Protocol defining delegate interface for Setting Sync Handler. * * It's implemented by SettingsProvider which owns Settings Sync Handlers * and sets itself as their delegate. */ -protocol SettingsSyncHandlingDelegate: AnyObject { +protocol SettingSyncHandlingDelegate: AnyObject { /** * This function must be called whenever setting's value changes for a given Setting Sync Handler. */ - func syncHandlerDidUpdateSettingValue(_ handler: SettingsSyncHandling) + func syncHandlerDidUpdateSettingValue(_ handler: SettingSyncHandling) } diff --git a/Sources/SyncDataProviders/Settings/internal/SettingsResponseHandler.swift b/Sources/SyncDataProviders/Settings/internal/SettingsResponseHandler.swift index 9fe3a4cae..7576a690b 100644 --- a/Sources/SyncDataProviders/Settings/internal/SettingsResponseHandler.swift +++ b/Sources/SyncDataProviders/Settings/internal/SettingsResponseHandler.swift @@ -38,7 +38,7 @@ final class SettingsResponseHandler { init( received: [Syncable], clientTimestamp: Date? = nil, - settingsHandlers: [SettingsProvider.Setting: any SettingsSyncHandling], + settingsHandlers: [SettingsProvider.Setting: any SettingSyncHandling], context: NSManagedObjectContext, crypter: Crypting, deduplicateEntities: Bool @@ -119,5 +119,5 @@ final class SettingsResponseHandler { } } - private let settingsHandlers: [SettingsProvider.Setting: any SettingsSyncHandling] + private let settingsHandlers: [SettingsProvider.Setting: any SettingSyncHandling] } diff --git a/Tests/BookmarksTests/BookmarkEntityTests.swift b/Tests/BookmarksTests/BookmarkEntityTests.swift index bc08ea5de..7583c758a 100644 --- a/Tests/BookmarksTests/BookmarkEntityTests.swift +++ b/Tests/BookmarksTests/BookmarkEntityTests.swift @@ -56,10 +56,10 @@ final class BookmarkEntityTests: XCTestCase { try! context.save() let rootFolder = BookmarkUtils.fetchRootFolder(context)! - let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context)! + let favoritesFolders = BookmarkEntity.Constants.favoriteFoldersIDs.map { BookmarkUtils.fetchFavoritesFolder(withUUID: $0, in: context)! } XCTAssertNil(rootFolder.modifiedAt) - XCTAssertNil(favoritesFolder.modifiedAt) + XCTAssertTrue(favoritesFolders.allSatisfy { $0.modifiedAt == nil }) } } diff --git a/Tests/BookmarksTests/BookmarkListViewModelTests.swift b/Tests/BookmarksTests/BookmarkListViewModelTests.swift index 0776e2add..19d6ffeb8 100644 --- a/Tests/BookmarksTests/BookmarkListViewModelTests.swift +++ b/Tests/BookmarksTests/BookmarkListViewModelTests.swift @@ -65,7 +65,12 @@ final class BookmarkListViewModelTests: XCTestCase { try! context.save() } - bookmarkListViewModel = BookmarkListViewModel(bookmarksDatabase: bookmarksDatabase, parentID: nil, errorEvents: eventMapping) + bookmarkListViewModel = BookmarkListViewModel( + bookmarksDatabase: bookmarksDatabase, + parentID: nil, + favoritesDisplayMode: .displayNative(.mobile), + errorEvents: eventMapping + ) } override func tearDown() { @@ -308,6 +313,231 @@ final class BookmarkListViewModelTests: XCTestCase { XCTAssertEqual(firedEvents, [.orphanedBookmarksPresent]) } } + + func testDisplayNativeMode_WhenBookmarkIsFavoritedThenItIsAddedToNativeAndUnifiedFolders() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + }) + } + } + + func testDisplayNativeMode_WhenNonNativeFavoriteIsFavoritedThenItIsAddedToNativeFolder() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + }) + } + } + + func testDisplayNativeMode_WhenNonNativeBrokenFavoriteIsFavoritedThenItIsAddedToNativeAndUnifiedFolder() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + }) + } + } + + func testDisplayNativeMode_WhenFavoriteIsUnfavoritedThenItIsRemovedFromNativeAndUnifiedFolder() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } + + func testDisplayNativeMode_WhenAllFormFactorsFavoriteIsUnfavoritedThenItIsOnlyRemovedFromNativeFolder() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop, .unified]) + }) + } + } + + func testDisplayAllMode_WhenBookmarkIsFavoritedThenItIsAddedToNativeAndUnifiedFolders() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + }) + } + } + + func testDisplayAllMode_WhenNonNativeFavoriteIsUnfavoritedThenItIsRemovedFromAllFolders() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } + + func testDisplayAllMode_WhenNonNativeBrokenFavoriteIsFavoritedThenItIsAddedToNativeAndUnifiedFolder() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + }) + } + } + + func testDisplayAllMode_WhenAllFormFactorsFavoriteIsUnfavoritedThenItIsRemovedFromAllFolders() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } } extension BookmarkEntity { diff --git a/Tests/BookmarksTests/BookmarkUtilsTests.swift b/Tests/BookmarksTests/BookmarkUtilsTests.swift new file mode 100644 index 000000000..75266145b --- /dev/null +++ b/Tests/BookmarksTests/BookmarkUtilsTests.swift @@ -0,0 +1,166 @@ +// +// BookmarkUtilsTests.swift +// DuckDuckGo +// +// Copyright © 2023 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 BookmarksTestsUtils +import XCTest +import Persistence +@testable import Bookmarks + +final class BookmarkUtilsTests: XCTestCase { + var bookmarksDatabase: CoreDataDatabase! + var location: URL! + + override func setUp() { + super.setUp() + + location = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + + let bundle = Bookmarks.bundle + guard let model = CoreDataDatabase.loadModel(from: bundle, named: "BookmarksModel") else { + XCTFail("Failed to load model") + return + } + bookmarksDatabase = CoreDataDatabase(name: className, containerLocation: location, model: model) + bookmarksDatabase.loadStore() + } + + override func tearDown() { + super.tearDown() + + try? bookmarksDatabase.tearDown(deleteStores: true) + bookmarksDatabase = nil + try? FileManager.default.removeItem(at: location) + } + + func testThatMigrationToFormFactorSpecificFavoritesAddsFavoritesToNativeFolder() async throws { + + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + BookmarkUtils.insertRootFolder(uuid: BookmarkEntity.Constants.rootFolderID, into: context) + BookmarkUtils.insertRootFolder(uuid: FavoritesFolderID.unified.rawValue, into: context) + try! context.save() + } + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.unified]) + Bookmark(id: "3", favoritedOn: [.unified]) + Bookmark(id: "4", favoritedOn: [.unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + + try! context.save() + + BookmarkUtils.migrateToFormFactorSpecificFavorites(byCopyingExistingTo: .mobile, in: context) + + try! context.save() + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + }) + } + } + + func testCopyFavoritesWhenDisablingSyncInDisplayNativeMode() async throws { + + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + BookmarkUtils.prepareFoldersStructure(in: context) + try! context.save() + } + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + Bookmark(id: "5", favoritedOn: [.desktop, .unified]) + Bookmark(id: "6", favoritedOn: [.desktop, .unified]) + Bookmark(id: "7", favoritedOn: [.desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + + try! context.save() + + BookmarkUtils.copyFavorites(from: .mobile, to: .unified, clearingNonNativeFavoritesFolder: .desktop, in: context) + + try! context.save() + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + Bookmark(id: "5") + Bookmark(id: "6") + Bookmark(id: "7") + }) + } + } + + func testCopyFavoritesWhenDisablingSyncInDisplayAllMode() async throws { + + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + BookmarkUtils.prepareFoldersStructure(in: context) + try! context.save() + } + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + Bookmark(id: "5", favoritedOn: [.desktop, .unified]) + Bookmark(id: "6", favoritedOn: [.desktop, .unified]) + Bookmark(id: "7", favoritedOn: [.desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + + try! context.save() + + BookmarkUtils.copyFavorites(from: .unified, to: .mobile, clearingNonNativeFavoritesFolder: .desktop, in: context) + + try! context.save() + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + Bookmark(id: "5", favoritedOn: [.mobile, .unified]) + Bookmark(id: "6", favoritedOn: [.mobile, .unified]) + Bookmark(id: "7", favoritedOn: [.mobile, .unified]) + }) + } + } +} diff --git a/Tests/BookmarksTests/FavoriteListViewModelTests.swift b/Tests/BookmarksTests/FavoriteListViewModelTests.swift index 49a4ccae3..f01cb2e21 100644 --- a/Tests/BookmarksTests/FavoriteListViewModelTests.swift +++ b/Tests/BookmarksTests/FavoriteListViewModelTests.swift @@ -52,8 +52,11 @@ final class FavoriteListViewModelTests: XCTestCase { try! context.save() } - favoriteListViewModel = FavoritesListViewModel(bookmarksDatabase: bookmarksDatabase, - errorEvents: eventMapping) + favoriteListViewModel = FavoritesListViewModel( + bookmarksDatabase: bookmarksDatabase, + errorEvents: eventMapping, + favoritesDisplayMode: .displayNative(.mobile) + ) } override func tearDown() { @@ -70,10 +73,10 @@ final class FavoriteListViewModelTests: XCTestCase { let context = favoriteListViewModel.context let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true, isDeleted: true) - Bookmark(id: "2", isFavorite: true) - Bookmark(id: "3", isFavorite: true, isDeleted: true) - Bookmark(id: "4", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified], isDeleted: true) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified], isDeleted: true) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) } context.performAndWait { @@ -81,7 +84,8 @@ final class FavoriteListViewModelTests: XCTestCase { try! context.save() - let rootFavoriteFolder = BookmarkUtils.fetchFavoritesFolder(context)! + let favoriteFolderUUID = favoriteListViewModel.favoritesDisplayMode.displayedFolder.rawValue + let rootFavoriteFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: favoriteFolderUUID, in: context)! XCTAssertEqual(rootFavoriteFolder.favoritesArray.map(\.title), ["2", "4"]) let bookmark = BookmarkEntity.fetchBookmark(withUUID: "2", context: context)! @@ -94,4 +98,103 @@ final class FavoriteListViewModelTests: XCTestCase { } } + func testDisplayNativeMode_WhenFavoriteIsUnfavoritedThenItIsRemovedFromNativeAndUnifiedFolder() async throws { + + favoriteListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = favoriteListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + favoriteListViewModel.reloadData() + favoriteListViewModel.removeFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } + + func testDisplayNativeMode_WhenAllFormFactorsFavoriteIsUnfavoritedThenItIsOnlyRemovedFromNativeFolder() async throws { + + favoriteListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = favoriteListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + favoriteListViewModel.reloadData() + favoriteListViewModel.removeFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop, .unified]) + }) + } + } + + func testDisplayAllMode_WhenNonNativeFavoriteIsUnfavoritedThenItIsRemovedFromAllFolders() async throws { + + favoriteListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = favoriteListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + favoriteListViewModel.reloadData() + favoriteListViewModel.removeFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } + + func testDisplayAllMode_WhenAllFormFactorsFavoriteIsUnfavoritedThenItIsRemovedFromAllFolders() async throws { + + favoriteListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = favoriteListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + favoriteListViewModel.reloadData() + favoriteListViewModel.removeFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } } diff --git a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksInitialSyncResponseHandlerTests.swift b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksInitialSyncResponseHandlerTests.swift index 0f223b06e..47e7e3b60 100644 --- a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksInitialSyncResponseHandlerTests.swift +++ b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksInitialSyncResponseHandlerTests.swift @@ -137,21 +137,23 @@ final class BookmarksInitialSyncResponseHandlerTests: BookmarksProviderTestsBase let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) } let received: [Syncable] = [ .rootFolder(children: ["1", "2", "3"]), .favoritesFolder(favorites: ["1", "2", "3"]), + .mobileFavoritesFolder(favorites: ["1", "2"]), + .desktopFavoritesFolder(favorites: ["3"]), .bookmark(id: "3") ] let rootFolder = try await createEntitiesAndHandleInitialSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.desktop, .unified]) }) } @@ -159,21 +161,22 @@ final class BookmarksInitialSyncResponseHandlerTests: BookmarksProviderTestsBase let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "4", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) } let received: [Syncable] = [ .rootFolder(children: ["3"]), .favoritesFolder(favorites: ["3"]), + .mobileFavoritesFolder(favorites: ["3"]), .bookmark(id: "3") ] let rootFolder = try await createEntitiesAndHandleInitialSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "4", isFavorite: true) - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) }) } @@ -350,24 +353,25 @@ final class BookmarksInitialSyncResponseHandlerTests: BookmarksProviderTestsBase let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) } let received: [Syncable] = [ .rootFolder(children: ["2"]), .favoritesFolder(favorites: ["2"]), + .mobileFavoritesFolder(favorites: ["2"]), .bookmark(id: "2") ] let rootFolder = try await createEntitiesAndHandleInitialSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) }) var favoritesFolder: BookmarkEntity! context.performAndWait { - favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) + favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.unified.rawValue, in: context) } XCTAssertNotNil(favoritesFolder.modifiedAt) } diff --git a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksProviderTests.swift b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksProviderTests.swift index deb15d360..9cac99338 100644 --- a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksProviderTests.swift +++ b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksProviderTests.swift @@ -41,7 +41,7 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark("Bookmark 1", id: "1", isFavorite: true) + Bookmark("Bookmark 1", id: "1", favoritedOn: [.mobile, .unified]) Bookmark("Bookmark 2", id: "2") Folder("Folder", id: "3") { Bookmark("Bookmark 4", id: "4") @@ -65,7 +65,7 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { let rootFolder = BookmarkUtils.fetchRootFolder(context)! assertEquivalent(rootFolder, BookmarkTree(modifiedAtConstraint: .notNil()) { - Bookmark("Bookmark 1", id: "1", isFavorite: true, modifiedAtConstraint: .notNil()) + Bookmark("Bookmark 1", id: "1", favoritedOn: [.mobile, .unified], modifiedAtConstraint: .notNil()) Bookmark("Bookmark 2", id: "2", modifiedAtConstraint: .notNil()) Folder("Folder", id: "3", modifiedAtConstraint: .notNil()) { Bookmark("Bookmark 4", id: "4", modifiedAtConstraint: .notNil()) @@ -74,8 +74,8 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { Bookmark("Bookmark 6", id: "6", modifiedAtConstraint: .notNil()) }) - let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context)! - XCTAssertNotNil(favoritesFolder.modifiedAt) + let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(for: .displayUnified(native: .mobile), in: context) + XCTAssertTrue(favoritesFolders.allSatisfy { $0.modifiedAt != nil }) } } @@ -83,7 +83,7 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) } context.performAndWait { @@ -97,7 +97,7 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { XCTAssertEqual( Set(changedObjects.compactMap(\.uuid)), - Set([BookmarkEntity.Constants.favoritesFolderID, BookmarkEntity.Constants.rootFolderID, "1"]) + BookmarkEntity.Constants.favoriteFoldersIDs.union(["1", BookmarkEntity.Constants.rootFolderID]) ) } @@ -105,7 +105,7 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { Bookmark(id: "3") Bookmark(id: "4") diff --git a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift index 211cb3c3d..21ea98364 100644 --- a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift +++ b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift @@ -115,21 +115,22 @@ final class BookmarksRegularSyncResponseHandlerTests: BookmarksProviderTestsBase let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) } let received: [Syncable] = [ .rootFolder(children: ["1", "2", "3"]), .favoritesFolder(favorites: ["1", "2", "3"]), + .mobileFavoritesFolder(favorites: ["1", "2", "3"]), .bookmark(id: "3") ] let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) }) } @@ -137,7 +138,7 @@ final class BookmarksRegularSyncResponseHandlerTests: BookmarksProviderTestsBase let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { Bookmark(id: "3") } @@ -145,64 +146,66 @@ final class BookmarksRegularSyncResponseHandlerTests: BookmarksProviderTestsBase let received: [Syncable] = [ .favoritesFolder(favorites: ["1", "3"]), + .mobileFavoritesFolder(favorites: ["1", "3"]), .folder(id: "2", children: ["3"]), .bookmark(id: "3") ] let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) } }) } - func testWhenPayloadDoesNotContainFavoritesFolderThenFavoritesAreNotAffected() async throws { + func testWhenPayloadContainsEmptyFavoritesFolderThenAllFavoritesAreRemoved() async throws { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) } } let received: [Syncable] = [ - .rootFolder(children: ["1", "2", "4"]), - .bookmark(id: "4") + .favoritesFolder(favorites: []), + .mobileFavoritesFolder(favorites: []) ] let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1") Folder(id: "2") { - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "3") } - Bookmark(id: "4") }) } - func testWhenPayloadContainsEmptyFavoritesFolderThenAllFavoritesAreRemoved() async throws { + func testWhenPayloadDoesNotContainFavoritesFolderThenFavoritesAreNotAffected() async throws { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) } } let received: [Syncable] = [ - .favoritesFolder(favorites: []) + .rootFolder(children: ["1", "2", "4"]), + .bookmark(id: "4") ] let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1") + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { - Bookmark(id: "3") + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) } + Bookmark(id: "4") }) } @@ -520,6 +523,66 @@ final class BookmarksRegularSyncResponseHandlerTests: BookmarksProviderTestsBase }) } + // MARK: - Invalid Favorites Form Factors + + func testWhenMobileOnlyFavoriteIsReceivedThenItIsSaved() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2") + } + + let received: [Syncable] = [ + .mobileFavoritesFolder(favorites: ["1"]) + ] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile]) + Bookmark(id: "2") + }) + } + + func testWhenUnifiedOnlyFavoriteIsReceivedThenItIsSaved() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2") + } + + let received: [Syncable] = [ + .favoritesFolder(favorites: ["1"]) + ] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.unified]) + Bookmark(id: "2") + }) + } + + func testWhenNonUnifiedFavoriteIsReceivedThenItIsSaved() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2") + } + + let received: [Syncable] = [ + .mobileFavoritesFolder(favorites: ["1"]), + .desktopFavoritesFolder(favorites: ["1", "2"]) + ] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop]) + Bookmark(id: "2", favoritedOn: [.desktop]) + }) + } + // MARK: - Helpers func createEntitiesAndHandleSyncResponse( diff --git a/Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift b/Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift deleted file mode 100644 index 9e0defdb0..000000000 --- a/Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// FormFactorSpecificFavoritesFoldersIgnoringTests.swift -// DuckDuckGo -// -// Copyright © 2023 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import XCTest -import Bookmarks -import BookmarksTestsUtils -import Common -import DDGSync -import Persistence -@testable import SyncDataProviders - -private extension Syncable { - static func desktopFavoritesFolder(favorites: [String]) -> Syncable { - .folder(id: "desktop_favorites_root", children: favorites) - } - - static func mobileFavoritesFolder(favorites: [String]) -> Syncable { - .folder(id: "mobile_favorites_root", children: favorites) - } -} - -final class FormFactorSpecificFavoritesFoldersIgnoringTests: BookmarksProviderTestsBase { - - func testThatDesktopFavoritesFolderIsIgnored() async throws { - let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) - - let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - } - - let received: [Syncable] = [.desktopFavoritesFolder(favorites: ["1", "2"])] - - let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) - assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - }) - } - - func testThatMobileFavoritesFolderIsIgnored() async throws { - let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) - - let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - } - - let received: [Syncable] = [.mobileFavoritesFolder(favorites: ["1", "2"])] - - let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) - assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - }) - } - - func testThatDesktopFavoritesFolderDoesNotAffectReceivedFavoritesFolder() async throws { - let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) - - let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - } - - let received: [Syncable] = [ - .favoritesFolder(favorites: ["1", "2"]), - .desktopFavoritesFolder(favorites: ["1", "2"]) - ] - - let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) - assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) - }) - } - - func testThatMobileFavoritesFolderDoesNotAffectReceivedFavoritesFolder() async throws { - let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) - - let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - } - - let received: [Syncable] = [ - .favoritesFolder(favorites: ["1", "2"]), - .mobileFavoritesFolder(favorites: ["1", "2"]) - ] - - let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) - assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) - }) - } - - // MARK: - Helpers - - func createEntitiesAndHandleSyncResponse( - with bookmarkTree: BookmarkTree, - sent: [Syncable] = [], - received: [Syncable], - clientTimestamp: Date = Date(), - serverTimestamp: String = "1234", - in context: NSManagedObjectContext - ) async throws -> BookmarkEntity { - - context.performAndWait { - BookmarkUtils.prepareFoldersStructure(in: context) - bookmarkTree.createEntities(in: context) - try! context.save() - } - - try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date(), serverTimestamp: "1234", crypter: crypter) - - var rootFolder: BookmarkEntity! - - context.performAndWait { - context.refreshAllObjects() - rootFolder = BookmarkUtils.fetchRootFolder(context) - } - - return rootFolder - } -} diff --git a/Tests/SyncDataProvidersTests/Bookmarks/helpers/SyncableBookmarksExtension.swift b/Tests/SyncDataProvidersTests/Bookmarks/helpers/SyncableBookmarksExtension.swift index 2e81571f5..bf30700f4 100644 --- a/Tests/SyncDataProvidersTests/Bookmarks/helpers/SyncableBookmarksExtension.swift +++ b/Tests/SyncDataProvidersTests/Bookmarks/helpers/SyncableBookmarksExtension.swift @@ -26,7 +26,15 @@ extension Syncable { } static func favoritesFolder(favorites: [String]) -> Syncable { - .folder(id: BookmarkEntity.Constants.favoritesFolderID, children: favorites) + .folder(id: FavoritesFolderID.unified.rawValue, children: favorites) + } + + static func mobileFavoritesFolder(favorites: [String]) -> Syncable { + .folder(id: FavoritesFolderID.mobile.rawValue, children: favorites) + } + + static func desktopFavoritesFolder(favorites: [String]) -> Syncable { + .folder(id: FavoritesFolderID.desktop.rawValue, children: favorites) } static func bookmark(_ title: String? = nil, id: String, url: String? = nil, lastModified: String? = nil, isDeleted: Bool = false) -> Syncable { diff --git a/Tests/SyncDataProvidersTests/CryptingMock.swift b/Tests/SyncDataProvidersTests/CryptingMock.swift index 846b95263..32810c95e 100644 --- a/Tests/SyncDataProvidersTests/CryptingMock.swift +++ b/Tests/SyncDataProvidersTests/CryptingMock.swift @@ -23,14 +23,16 @@ import Foundation struct CryptingMock: Crypting { + static let reservedFolderIDs = Set(FavoritesFolderID.allCases.map(\.rawValue)).union([BookmarkEntity.Constants.rootFolderID]) + var _encryptAndBase64Encode: (String) throws -> String = { value in - if [BookmarkEntity.Constants.favoritesFolderID, BookmarkEntity.Constants.rootFolderID].contains(value) { + if Self.reservedFolderIDs.contains(value) { return value } return "encrypted_\(value)" } var _base64DecodeAndDecrypt: (String) throws -> String = { value in - if [BookmarkEntity.Constants.favoritesFolderID, BookmarkEntity.Constants.rootFolderID].contains(value) { + if Self.reservedFolderIDs.contains(value) { return value } return value.dropping(prefix: "encrypted_") diff --git a/Tests/SyncDataProvidersTests/Settings/SettingsInitialSyncResponseHandlerTests.swift b/Tests/SyncDataProvidersTests/Settings/SettingsInitialSyncResponseHandlerTests.swift index 0561df720..7ef3b9007 100644 --- a/Tests/SyncDataProvidersTests/Settings/SettingsInitialSyncResponseHandlerTests.swift +++ b/Tests/SyncDataProvidersTests/Settings/SettingsInitialSyncResponseHandlerTests.swift @@ -63,7 +63,7 @@ final class SettingsInitialSyncResponseHandlerTests: SettingsProviderTestsBase { XCTAssertNil(emailManagerStorage.mockToken) } - func testThatEmailProtectionIsEnabledLocallyAndRemotelyThenRemoteStateIsApplied() async throws { + func testWhenEmailProtectionIsEnabledLocallyAndRemotelyThenRemoteStateIsApplied() async throws { let emailManager = EmailManager(storage: emailManagerStorage) try emailManager.signIn(username: "dax-local", token: "secret-token-local") @@ -98,4 +98,68 @@ final class SettingsInitialSyncResponseHandlerTests: SettingsProviderTestsBase { XCTAssertEqual(emailManagerStorage.mockUsername, "dax") XCTAssertEqual(emailManagerStorage.mockToken, "secret-token") } + + func testThatSettingStateIsAppliedLocally() async throws { + testSettingSyncHandler.syncedValue = nil + + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await handleInitialSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "remote") + } + + func testThatDeletedSettingIsIgnoredWhenLocallyIsNil() async throws { + testSettingSyncHandler.syncedValue = nil + + let received: [Syncable] = [ + .testSettingDeleted() + ] + + try await handleInitialSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertNil(testSettingSyncHandler.syncedValue) + } + + func testWhenSettingIsNotNilLocallyAndRemotelyThenRemoteStateIsApplied() async throws { + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await handleInitialSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "remote") + } + + func testThatSettingValueIsDeduplicated() async throws { + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSetting("local") + ] + + try await handleInitialSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "local") + } } diff --git a/Tests/SyncDataProvidersTests/Settings/SettingsProviderTests.swift b/Tests/SyncDataProvidersTests/Settings/SettingsProviderTests.swift index a553dc79e..902726e38 100644 --- a/Tests/SyncDataProvidersTests/Settings/SettingsProviderTests.swift +++ b/Tests/SyncDataProvidersTests/Settings/SettingsProviderTests.swift @@ -37,7 +37,7 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertEqual(provider.lastSyncTimestamp, "12345") } - func testThatPrepareForFirstSyncClearsLastSyncTimestampAndSetsModifiedAtForEmailSettings() throws { + func testThatPrepareForFirstSyncClearsLastSyncTimestampAndSetsModifiedAtForAllSettings() throws { try emailManager.signIn(username: "dax", token: "secret-token") @@ -50,7 +50,7 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertNil(provider.lastSyncTimestamp) settingsMetadata = fetchAllSettingsMetadata(in: context) - XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.count, 2) XCTAssertTrue(settingsMetadata.allSatisfy { $0.lastModified != nil }) } @@ -67,6 +67,23 @@ final class SettingsProviderTests: SettingsProviderTestsBase { ) } + func testThatFetchChangedObjectsReturnsTestSettingWithNonNilModifiedAt() async throws { + + testSettingSyncHandler.syncedValue = "1" + + let changedObjects = try await provider.fetchChangedObjects(encryptedUsing: crypter).map(SyncableSettingAdapter.init) + + XCTAssertEqual( + Set(changedObjects.compactMap(\.uuid)), + Set([SettingsProvider.Setting.testSetting.key]) + ) + } + + func testThatFetchChangedObjectsReturnsEmptyArrayWhenNothingHasChanged() async throws { + let changedObjects = try await provider.fetchChangedObjects(encryptedUsing: crypter).map(SyncableSettingAdapter.init) + XCTAssertTrue(changedObjects.isEmpty) + } + func testWhenEmailProtectionIsDisabledThenFetchChangedObjectsContainsDeletedSyncable() async throws { let otherEmailManager = EmailManager(storage: MockEmailManagerStorage()) @@ -82,6 +99,21 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertEqual(syncable.uuid, SettingsProvider.Setting.emailProtectionGeneration.key) } + func testWhenTestSettingIsClearedThenFetchChangedObjectsContainsDeletedSyncable() async throws { + + testSettingSyncHandler.syncedValue = "1" + testSettingSyncHandler.syncedValue = nil + + let changedObjects = try await provider.fetchChangedObjects(encryptedUsing: crypter).map(SyncableSettingAdapter.init) + + XCTAssertEqual(changedObjects.count, 1) + + let syncable = try XCTUnwrap(changedObjects.first) + + XCTAssertTrue(syncable.isDeleted) + XCTAssertEqual(syncable.uuid, SettingsProvider.Setting.testSetting.key) + } + func testThatSigninInToEmailProtectionStateUpdatesSyncMetadataTimestamp() async throws { try provider.prepareForFirstSync() @@ -89,9 +121,9 @@ final class SettingsProviderTests: SettingsProviderTestsBase { let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let initialSettingsMetadata = fetchAllSettingsMetadata(in: context) - let initialEmailMetadata = try XCTUnwrap(initialSettingsMetadata.first) + let initialEmailMetadata = try XCTUnwrap(initialSettingsMetadata.first(where: { $0.key == SettingsProvider.Setting.emailProtectionGeneration.key })) let initialTimestamp = initialEmailMetadata.lastModified - XCTAssertEqual(initialSettingsMetadata.count, 1) + XCTAssertEqual(initialSettingsMetadata.count, 2) XCTAssertNotNil(initialTimestamp) try await Task.sleep(nanoseconds: 1000) @@ -102,9 +134,9 @@ final class SettingsProviderTests: SettingsProviderTestsBase { context.refreshAllObjects() let settingsMetadata = fetchAllSettingsMetadata(in: context) - let emailMetadata = try XCTUnwrap(settingsMetadata.first) + let emailMetadata = try XCTUnwrap(settingsMetadata.first(where: { $0.key == SettingsProvider.Setting.emailProtectionGeneration.key })) let timestamp = emailMetadata.lastModified - XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.count, 2) XCTAssertNotNil(timestamp) XCTAssertTrue(timestamp! > initialTimestamp!) @@ -119,9 +151,9 @@ final class SettingsProviderTests: SettingsProviderTestsBase { let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let initialSettingsMetadata = fetchAllSettingsMetadata(in: context) - let initialEmailMetadata = try XCTUnwrap(initialSettingsMetadata.first) + let initialEmailMetadata = try XCTUnwrap(initialSettingsMetadata.first(where: { $0.key == SettingsProvider.Setting.emailProtectionGeneration.key })) let initialTimestamp = initialEmailMetadata.lastModified - XCTAssertEqual(initialSettingsMetadata.count, 1) + XCTAssertEqual(initialSettingsMetadata.count, 2) XCTAssertNotNil(initialTimestamp) try await Task.sleep(nanoseconds: 1000) @@ -132,9 +164,36 @@ final class SettingsProviderTests: SettingsProviderTestsBase { context.refreshAllObjects() let settingsMetadata = fetchAllSettingsMetadata(in: context) - let emailMetadata = try XCTUnwrap(settingsMetadata.first) + let emailMetadata = try XCTUnwrap(settingsMetadata.first(where: { $0.key == SettingsProvider.Setting.emailProtectionGeneration.key })) let timestamp = emailMetadata.lastModified - XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.count, 2) + XCTAssertNotNil(timestamp) + + XCTAssertTrue(timestamp! > initialTimestamp!) + } + + func testThatUpdatingSettingValueUpdatesSyncMetadataTimestamp() async throws { + + try provider.prepareForFirstSync() + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let initialSettingsMetadata = fetchAllSettingsMetadata(in: context) + let initialTestSettingMetadata = try XCTUnwrap(initialSettingsMetadata.first(where: { $0.key == SettingsProvider.Setting.testSetting.key })) + let initialTimestamp = initialTestSettingMetadata.lastModified + XCTAssertEqual(initialSettingsMetadata.count, 2) + XCTAssertNotNil(initialTimestamp) + + try await Task.sleep(nanoseconds: 1000) + + testSettingSyncHandler.syncedValue = "1" + + context.refreshAllObjects() + + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first(where: { $0.key == SettingsProvider.Setting.testSetting.key })) + let timestamp = testSettingMetadata.lastModified + XCTAssertEqual(settingsMetadata.count, 2) XCTAssertNotNil(timestamp) XCTAssertTrue(timestamp! > initialTimestamp!) @@ -169,11 +228,11 @@ final class SettingsProviderTests: SettingsProviderTestsBase { let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let settingsMetadata = fetchAllSettingsMetadata(in: context) - let emailMetadata = try XCTUnwrap(settingsMetadata.first) + let emailMetadata = try XCTUnwrap(settingsMetadata.first(where: { $0.key == SettingsProvider.Setting.emailProtectionGeneration.key })) XCTAssertNil(emailMetadata.lastModified) } - func testThatInitialSyncClearsLastModifiedForDeduplicatedCredential() async throws { + func testThatInitialSyncClearsLastModifiedForDeduplicatedEmailProtectionSetting() async throws { let date = Date() @@ -192,6 +251,24 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertNil(emailMetadata.lastModified) } + func testThatInitialSyncClearsLastModifiedForDeduplicatedSetting() async throws { + + let date = Date() + + testSettingSyncHandler.syncedValue = "1" + + let received: [Syncable] = [ + .testSetting("1") + ] + + try await provider.handleInitialSyncResponse(received: received, clientTimestamp: date.addingTimeInterval(1), serverTimestamp: "1234", crypter: crypter) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + } + func testWhenThereIsMergeConflictDuringInitialSyncThenSyncResponseHandlingIsRetried() async throws { let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) @@ -250,6 +327,24 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertEqual(emailManagerStorage.mockToken, "secret-token2") } + func testWhenSettingDeleteIsSentAndUpdateIsReceivedThenSettingIsNotDeleted() async throws { + testSettingSyncHandler.syncedValue = "local" + testSettingSyncHandler.syncedValue = nil + + let received: [Syncable] = [ + .testSetting("remote") + ] + + let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) + try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date(), serverTimestamp: "1234", crypter: crypter) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "remote") + } + func testWhenEmailProtectionWasSentAndThenDisabledLocallyAndAnUpdateIsReceivedThenEmailProtectionIsDisabled() async throws { let emailManager = EmailManager(storage: emailManagerStorage) @@ -274,6 +369,28 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertNil(emailManagerStorage.mockToken) } + func testWhenSettingWasSentAndThenDeletedLocallyAndAnUpdateIsReceivedThenSettingIsDeleted() async throws { + + testSettingSyncHandler.syncedValue = "local" + + let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) + + testSettingSyncHandler.syncedValue = nil + + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date().advanced(by: -1), serverTimestamp: "1234", crypter: crypter) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.first?.key, SettingsProvider.Setting.testSetting.key) + XCTAssertNotNil(settingsMetadata.first?.lastModified) + XCTAssertNil(testSettingSyncHandler.syncedValue) + } + func testWhenEmailProtectionWasEnabledLocallyAfterStartingSyncThenRemoteChangesAreDropped() async throws { let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) @@ -296,6 +413,26 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertEqual(emailManagerStorage.mockToken, "secret-token") } + func testWhenSettingWasUpdatedLocallyAfterStartingSyncThenRemoteChangesAreDropped() async throws { + + let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) + + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date().advanced(by: -1), serverTimestamp: "1234", crypter: crypter) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.first?.key, SettingsProvider.Setting.testSetting.key) + XCTAssertNotNil(settingsMetadata.first?.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "local") + } + func testWhenEmailProtectionWasEnabledLocallyAfterStartingSyncThenRemoteDisableIsDropped() async throws { let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) @@ -318,6 +455,26 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertEqual(emailManagerStorage.mockToken, "secret-token") } + func testWhenSettingWasUpdatedLocallyAfterStartingSyncThenRemoteDeleteIsDropped() async throws { + + let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) + + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSettingDeleted() + ] + + try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date().advanced(by: -1), serverTimestamp: "1234", crypter: crypter) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.first?.key, SettingsProvider.Setting.testSetting.key) + XCTAssertNotNil(settingsMetadata.first?.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "local") + } + func testWhenThereIsMergeConflictDuringRegularSyncThenSyncResponseHandlingIsRetried() async throws { let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) diff --git a/Tests/SyncDataProvidersTests/Settings/SettingsRegularSyncResponseHandlerTests.swift b/Tests/SyncDataProvidersTests/Settings/SettingsRegularSyncResponseHandlerTests.swift index 4db01e946..8d6219617 100644 --- a/Tests/SyncDataProvidersTests/Settings/SettingsRegularSyncResponseHandlerTests.swift +++ b/Tests/SyncDataProvidersTests/Settings/SettingsRegularSyncResponseHandlerTests.swift @@ -59,7 +59,7 @@ final class SettingsRegularSyncResponseHandlerTests: SettingsProviderTestsBase { XCTAssertNil(emailManagerStorage.mockToken) } - func testThatEmailProtectionIsEnabledLocallyAndRemotelyThenRemoteStateIsApplied() async throws { + func testWhenEmailProtectionIsEnabledLocallyAndRemotelyThenRemoteStateIsApplied() async throws { let emailManager = EmailManager(storage: emailManagerStorage) try emailManager.signIn(username: "dax-local", token: "secret-token-local") @@ -76,4 +76,49 @@ final class SettingsRegularSyncResponseHandlerTests: SettingsProviderTestsBase { XCTAssertEqual(emailManagerStorage.mockUsername, "dax-remote") XCTAssertEqual(emailManagerStorage.mockToken, "secret-token-remote") } + + func testThatSettingStateIsApplied() async throws { + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await handleSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + XCTAssertTrue(settingsMetadata.isEmpty) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "remote") + } + + func testThatSettingDeletedStateIsApplied() async throws { + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSettingDeleted() + ] + + try await handleSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertNil(testSettingSyncHandler.syncedValue) + } + + func testWhenSettingIsSetLocallyAndRemotelyThenRemoteStateIsApplied() async throws { + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await handleSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "remote") + } } diff --git a/Tests/SyncDataProvidersTests/Settings/helpers/SettingsProviderTestsBase.swift b/Tests/SyncDataProvidersTests/Settings/helpers/SettingsProviderTestsBase.swift index 218394170..081e6e494 100644 --- a/Tests/SyncDataProvidersTests/Settings/helpers/SettingsProviderTestsBase.swift +++ b/Tests/SyncDataProvidersTests/Settings/helpers/SettingsProviderTestsBase.swift @@ -111,6 +111,7 @@ internal class SettingsProviderTestsBase: XCTestCase { var metadataDatabaseLocation: URL! var crypter = CryptingMock() var provider: SettingsProvider! + var testSettingSyncHandler: TestSettingSyncHandler! func setUpSyncMetadataDatabase() { metadataDatabaseLocation = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -127,14 +128,17 @@ internal class SettingsProviderTestsBase: XCTestCase { override func setUpWithError() throws { super.setUp() - setUpSyncMetadataDatabase() - emailManagerStorage = MockEmailManagerStorage() emailManager = EmailManager(storage: emailManagerStorage) + let emailProtectionSyncHandler = EmailProtectionSyncHandler(emailManager: emailManager) + testSettingSyncHandler = .init() + + setUpSyncMetadataDatabase() + provider = SettingsProvider( metadataDatabase: metadataDatabase, metadataStore: LocalSyncMetadataStore(database: metadataDatabase), - emailManager: emailManager, + settingsHandlers: [emailProtectionSyncHandler, testSettingSyncHandler], syncDidUpdateData: {} ) } diff --git a/Tests/SyncDataProvidersTests/Settings/helpers/SyncableSettingsExtension.swift b/Tests/SyncDataProvidersTests/Settings/helpers/SyncableSettingsExtension.swift index 0948e32f9..57efdae4b 100644 --- a/Tests/SyncDataProvidersTests/Settings/helpers/SyncableSettingsExtension.swift +++ b/Tests/SyncDataProvidersTests/Settings/helpers/SyncableSettingsExtension.swift @@ -17,6 +17,7 @@ // limitations under the License. // +import Bookmarks import BrowserServicesKit import DDGSync import Foundation @@ -44,6 +45,14 @@ extension Syncable { } static func emailProtectionDeleted() -> Syncable { - return Self.settings(SettingsProvider.Setting.emailProtectionGeneration, value: nil, isDeleted: true) + Self.settings(SettingsProvider.Setting.emailProtectionGeneration, value: nil, isDeleted: true) + } + + static func testSetting(_ value: String, lastModified: String? = nil, isDeleted: Bool = false) -> Syncable { + Self.settings(SettingsProvider.Setting.testSetting, value: "encrypted_\(value)", lastModified: lastModified, isDeleted: isDeleted) + } + + static func testSettingDeleted() -> Syncable { + Self.settings(SettingsProvider.Setting.testSetting, value: nil, isDeleted: true) } } diff --git a/Tests/SyncDataProvidersTests/Settings/helpers/TestSettingSyncHandler.swift b/Tests/SyncDataProvidersTests/Settings/helpers/TestSettingSyncHandler.swift new file mode 100644 index 000000000..f7448e249 --- /dev/null +++ b/Tests/SyncDataProvidersTests/Settings/helpers/TestSettingSyncHandler.swift @@ -0,0 +1,61 @@ +// +// TestSettingHandler.swift +// DuckDuckGo +// +// Copyright © 2023 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 Bookmarks +import Combine +import Foundation +import SyncDataProviders + +extension SettingsProvider.Setting { + static let testSetting = SettingsProvider.Setting(key: "test_setting") +} + +final class TestSettingSyncHandler: SettingSyncHandler { + + override var setting: SettingsProvider.Setting { + .testSetting + } + + override func getValue() throws -> String? { + syncedValue + } + + override func setValue(_ value: String?) throws { + DispatchQueue.main.async { + self.notifyValueDidChange = false + self.syncedValue = value + } + } + + override var valueDidChangePublisher: AnyPublisher { + $syncedValue.dropFirst().map({ _ in }) + .filter { [weak self] in + self?.notifyValueDidChange == true + } + .eraseToAnyPublisher() + } + + @Published var syncedValue: String? { + didSet { + notifyValueDidChange = true + } + } + + private var notifyValueDidChange: Bool = true +}