diff --git a/Core/BookmarksStateValidation.swift b/Core/BookmarksStateValidation.swift index fc02e527f8..2ca6bcab60 100644 --- a/Core/BookmarksStateValidation.swift +++ b/Core/BookmarksStateValidation.swift @@ -22,7 +22,15 @@ import CoreData import Bookmarks import Persistence -public class BookmarksStateValidation { +public protocol BookmarksStateValidation { + + func validateInitialState(context: NSManagedObjectContext, + validationError: BookmarksStateValidator.ValidationError) -> Bool + + func validateBookmarksStructure(context: NSManagedObjectContext) +} + +public class BookmarksStateValidator: BookmarksStateValidation { enum Constants { static let bookmarksDBIsInitialized = "bookmarksDBIsInitialized" @@ -30,6 +38,7 @@ public class BookmarksStateValidation { public enum ValidationError { case bookmarksStructureLost + case bookmarksStructureNotRecovered case bookmarksStructureBroken(additionalParams: [String: String]) case validatorError(Error) } @@ -43,18 +52,22 @@ public class BookmarksStateValidation { self.errorHandler = errorHandler } - public func validateInitialState(context: NSManagedObjectContext) { - guard keyValueStore.object(forKey: Constants.bookmarksDBIsInitialized) != nil else { return } + public func validateInitialState(context: NSManagedObjectContext, + validationError: ValidationError) -> Bool { + guard keyValueStore.object(forKey: Constants.bookmarksDBIsInitialized) != nil else { return true } let fetch = BookmarkEntity.fetchRequest() do { let count = try context.count(for: fetch) if count == 0 { - errorHandler(.bookmarksStructureLost) + errorHandler(validationError) + return false } } catch { errorHandler(.validatorError(error)) } + + return true } public func validateBookmarksStructure(context: NSManagedObjectContext) { diff --git a/Core/LegacyBookmarksStoreMigration.swift b/Core/LegacyBookmarksStoreMigration.swift index 268a81e2e7..0095a7ccd7 100644 --- a/Core/LegacyBookmarksStoreMigration.swift +++ b/Core/LegacyBookmarksStoreMigration.swift @@ -39,7 +39,14 @@ public class LegacyBookmarksStoreMigration { } } else { // Initialize structure if needed - BookmarkUtils.prepareLegacyFoldersStructure(in: context) + do { + try BookmarkUtils.prepareLegacyFoldersStructure(in: context) + } catch { + Pixel.fire(pixel: .debugBookmarksInitialStructureQueryFailed, error: error) + Thread.sleep(forTimeInterval: 1) + fatalError("Could not prepare Bookmarks DB structure") + } + if context.hasChanges { do { try context.save(onErrorFire: .bookmarksCouldNotPrepareDatabase) @@ -178,7 +185,7 @@ public class LegacyBookmarksStoreMigration { } catch { destination.reset() - BookmarkUtils.prepareLegacyFoldersStructure(in: destination) + try? BookmarkUtils.prepareLegacyFoldersStructure(in: destination) do { try destination.save(onErrorFire: .bookmarksMigrationCouldNotPrepareDatabaseOnFailedMigration) } catch { diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index e6c6e2014b..2748793c24 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -513,7 +513,9 @@ extension Pixel { case adAttributionLogicWrongVendorOnSuccessfulCompilation case adAttributionLogicWrongVendorOnFailedCompilation + case debugBookmarksInitialStructureQueryFailed case debugBookmarksStructureLost + case debugBookmarksStructureNotRecovered case debugBookmarksInvalidRoots case debugBookmarksValidationFailed @@ -589,8 +591,6 @@ extension Pixel { case syncDeleteAccountError case syncLoginExistingAccountError - case syncWrongEnvironment - case swipeTabsUsedDaily case swipeToOpenNewTab @@ -1210,8 +1210,10 @@ extension Pixel.Event { return "m_compilation_result_\(result)_time_\(waitTime)_state_\(appState)" case .emailAutofillKeychainError: return "m_email_autofill_keychain_error" - + + case .debugBookmarksInitialStructureQueryFailed: return "m_d_bookmarks-initial-structure-query-failed" case .debugBookmarksStructureLost: return "m_d_bookmarks_structure_lost" + case .debugBookmarksStructureNotRecovered: return "m_d_bookmarks_structure_not_recovered" case .debugBookmarksInvalidRoots: return "m_d_bookmarks_invalid_roots" case .debugBookmarksValidationFailed: return "m_d_bookmarks_validation_failed" @@ -1295,8 +1297,6 @@ extension Pixel.Event { case .syncDeleteAccountError: return "m_d_sync_delete_account_error" case .syncLoginExistingAccountError: return "m_d_sync_login_existing_account_error" - case .syncWrongEnvironment: return "m_d_sync_wrong_environment_u" - case .swipeTabsUsedDaily: return "m_swipe-tabs-used-daily" case .swipeToOpenNewTab: return "m_addressbar_swipe_new_tab" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index bb0e18b01a..35e15de26c 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -583,6 +583,7 @@ 987130C7294AAB9F00AB05E0 /* MenuBookmarksViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987130C1294AAB9E00AB05E0 /* MenuBookmarksViewModelTests.swift */; }; 987130C8294AAB9F00AB05E0 /* BookmarksTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987130C2294AAB9E00AB05E0 /* BookmarksTestHelpers.swift */; }; 987130C9294AAB9F00AB05E0 /* BookmarkUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987130C3294AAB9E00AB05E0 /* BookmarkUtilsTests.swift */; }; + 987243142C5232B5007ECC76 /* BookmarksDatabaseSetupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987243132C5232B5007ECC76 /* BookmarksDatabaseSetupTests.swift */; }; 9872D205247DCAC100CEF398 /* TabPreviewsSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9872D204247DCAC100CEF398 /* TabPreviewsSource.swift */; }; 9874F9EE2187AFCE00CAF33D /* Themable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9874F9ED2187AFCE00CAF33D /* Themable.swift */; }; 9875E00722316B8400B1373F /* Instruments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9875E00622316B8400B1373F /* Instruments.swift */; }; @@ -2193,6 +2194,7 @@ 987130C1294AAB9E00AB05E0 /* MenuBookmarksViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MenuBookmarksViewModelTests.swift; sourceTree = ""; }; 987130C2294AAB9E00AB05E0 /* BookmarksTestHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarksTestHelpers.swift; sourceTree = ""; }; 987130C3294AAB9E00AB05E0 /* BookmarkUtilsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkUtilsTests.swift; sourceTree = ""; }; + 987243132C5232B5007ECC76 /* BookmarksDatabaseSetupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDatabaseSetupTests.swift; sourceTree = ""; }; 9872D204247DCAC100CEF398 /* TabPreviewsSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPreviewsSource.swift; sourceTree = ""; }; 9874F9ED2187AFCE00CAF33D /* Themable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Themable.swift; sourceTree = ""; }; 9875E00622316B8400B1373F /* Instruments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instruments.swift; sourceTree = ""; }; @@ -5443,6 +5445,7 @@ 85BA58561F34F61C00C6E8CA /* AppUserDefaultsTests.swift */, 4B62C4B925B930DD008912C6 /* AppConfigurationFetchTests.swift */, 85AFA1202B45D14F0028A504 /* BookmarksMigrationAssertionTests.swift */, + 987243132C5232B5007ECC76 /* BookmarksDatabaseSetupTests.swift */, ); name = Application; sourceTree = ""; @@ -7320,6 +7323,7 @@ EEFE9C732A603CE9005B0A26 /* NetworkProtectionStatusViewModelTests.swift in Sources */, F13B4BF91F18CA0600814661 /* TabsModelTests.swift in Sources */, F1BDDBFD2C340D9C00459306 /* SubscriptionContainerViewModelTests.swift in Sources */, + 987243142C5232B5007ECC76 /* BookmarksDatabaseSetupTests.swift in Sources */, 98B31290218CCB2200E54DE1 /* MockDependencyProvider.swift in Sources */, CBDD5DDF29A6736A00832877 /* APIHeadersTests.swift in Sources */, 986B45D0299E30A50089D2D7 /* BookmarkEntityTests.swift in Sources */, @@ -10281,7 +10285,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 179.0.0; + version = 180.0.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 469846d5af..ef47d74ec4 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "dfddead0e1e4735a021d3affb05b64fea561a807", - "version" : "179.0.0" + "revision" : "92ecebfb4172ab9561959a07d7ef7037aea8c6e1", + "version" : "180.0.0" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/duckduckgo-autofill.git", "state" : { - "revision" : "9fea1c6762db726328b14bb9ebfd6508849eae28", - "version" : "12.1.0" + "revision" : "2b81745565db09eee8c1cd44d38eefa1011a9f0a", + "version" : "12.0.1" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/GRDB.swift.git", "state" : { - "revision" : "4225b85c9a0c50544e413a1ea1e502c802b44b35", - "version" : "2.4.0" + "revision" : "9f049d7b97b1e68ffd86744b500660d34a9e79b8", + "version" : "2.3.0" } }, { @@ -138,7 +138,7 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", "version" : "1.4.0" diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index c0630a31b5..c8925c5b62 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -170,7 +170,7 @@ import WebKit Pixel.isDryRun = true _ = DefaultUserAgentManager.shared Database.shared.loadStore { _, _ in } - _ = BookmarksDatabaseSetup(crashOnError: true).loadStoreAndMigrate(bookmarksDatabase: bookmarksDatabase) + _ = BookmarksDatabaseSetup().loadStoreAndMigrate(bookmarksDatabase: bookmarksDatabase) window?.rootViewController = UIStoryboard.init(name: "LaunchScreen", bundle: nil).instantiateInitialViewController() return true } @@ -209,9 +209,18 @@ import WebKit DatabaseMigration.migrate(to: context) } - if BookmarksDatabaseSetup(crashOnError: !shouldPresentInsufficientDiskSpaceAlertAndCrash) - .loadStoreAndMigrate(bookmarksDatabase: bookmarksDatabase) { - // MARK: post-Bookmarks migration logic + switch BookmarksDatabaseSetup().loadStoreAndMigrate(bookmarksDatabase: bookmarksDatabase) { + case .success: + break + case .failure(let error): + Pixel.fire(pixel: .bookmarksCouldNotLoadDatabase, + error: error) + if error.isDiskFull { + shouldPresentInsufficientDiskSpaceAlertAndCrash = true + } else { + Thread.sleep(forTimeInterval: 1) + fatalError("Could not create database stack: \(error.localizedDescription)") + } } WidgetCenter.shared.reloadAllTimelines() @@ -308,35 +317,40 @@ import WebKit let tabsModel = prepareTabsModel(previewsSource: previewsSource) privacyProDataReporter.injectTabsModel(tabsModel) + + if shouldPresentInsufficientDiskSpaceAlertAndCrash { - let main = MainViewController(bookmarksDatabase: bookmarksDatabase, - bookmarksDatabaseCleaner: syncDataProviders.bookmarksAdapter.databaseCleaner, - historyManager: historyManager, - homePageConfiguration: homePageConfiguration, - syncService: syncService, - syncDataProviders: syncDataProviders, - appSettings: AppDependencyProvider.shared.appSettings, - previewsSource: previewsSource, - tabsModel: tabsModel, - syncPausedStateManager: syncErrorHandler, - privacyProDataReporter: privacyProDataReporter) - - main.loadViewIfNeeded() - syncErrorHandler.alertPresenter = main - - window = UIWindow(frame: UIScreen.main.bounds) - window?.rootViewController = main - window?.makeKeyAndVisible() + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = BlankSnapshotViewController(appSettings: AppDependencyProvider.shared.appSettings) + window?.makeKeyAndVisible() - if shouldPresentInsufficientDiskSpaceAlertAndCrash { presentInsufficientDiskSpaceAlert() - } + } else { + let main = MainViewController(bookmarksDatabase: bookmarksDatabase, + bookmarksDatabaseCleaner: syncDataProviders.bookmarksAdapter.databaseCleaner, + historyManager: historyManager, + homePageConfiguration: homePageConfiguration, + syncService: syncService, + syncDataProviders: syncDataProviders, + appSettings: AppDependencyProvider.shared.appSettings, + previewsSource: previewsSource, + tabsModel: tabsModel, + syncPausedStateManager: syncErrorHandler, + privacyProDataReporter: privacyProDataReporter) + + main.loadViewIfNeeded() + syncErrorHandler.alertPresenter = main + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = main + window?.makeKeyAndVisible() - autoClear = AutoClear(worker: main) - let applicationState = application.applicationState - Task { - await autoClear?.clearDataIfEnabled(applicationState: .init(with: applicationState)) - await vpnWorkaround.installRedditSessionWorkaround() + autoClear = AutoClear(worker: main) + let applicationState = application.applicationState + Task { + await autoClear?.clearDataIfEnabled(applicationState: .init(with: applicationState)) + await vpnWorkaround.installRedditSessionWorkaround() + } } AppDependencyProvider.shared.voiceSearchHelper.migrateSettingsFlagIfNecessary() @@ -473,10 +487,6 @@ import WebKit guard !testing else { return } syncService.initializeIfNeeded() - if syncService.authState == .active && - (InternalUserStore().isInternalUser == false && syncService.serverEnvironment == .development) { - UniquePixel.fire(pixel: .syncWrongEnvironment) - } syncDataProviders.setUpDatabaseCleanersIfNeeded(syncService: syncService) if !(overlayWindow?.rootViewController is AuthenticationViewController) { @@ -1052,6 +1062,11 @@ private extension Error { if let underlyingError = nsError.userInfo["NSUnderlyingError"] as? NSError, underlyingError.code == 13 { return true } + + if nsError.userInfo["NSSQLiteErrorDomain"] as? Int == 13 { + return true + } + return false } diff --git a/DuckDuckGo/BookmarksDatabaseSetup.swift b/DuckDuckGo/BookmarksDatabaseSetup.swift index 93364b97ba..fcd83415fe 100644 --- a/DuckDuckGo/BookmarksDatabaseSetup.swift +++ b/DuckDuckGo/BookmarksDatabaseSetup.swift @@ -26,24 +26,24 @@ import Common struct BookmarksDatabaseSetup { - let crashOnError: Bool - - private let migrationAssertion = BookmarksMigrationAssertion() + enum Result { + case success + case failure(Error) + } - func loadStoreAndMigrate(bookmarksDatabase: CoreDataDatabase) -> Bool { - let preMigrationErrorHandling = createErrorHandling() + private let migrationAssertion: BookmarksMigrationAssertion - let oldFavoritesOrder = BookmarkFormFactorFavoritesMigration - .getFavoritesOrderFromPreV4Model( - dbContainerLocation: BookmarksDatabase.defaultDBLocation, - dbFileURL: BookmarksDatabase.defaultDBFileURL, - errorEvents: preMigrationErrorHandling - ) + init(migrationAssertion: BookmarksMigrationAssertion = BookmarksMigrationAssertion()) { + self.migrationAssertion = migrationAssertion + } - let validator = BookmarksStateValidation(keyValueStore: UserDefaults.app) { validationError in + static func makeValidator() -> BookmarksStateValidator { + return BookmarksStateValidator(keyValueStore: UserDefaults.app) { validationError in switch validationError { case .bookmarksStructureLost: DailyPixel.fire(pixel: .debugBookmarksStructureLost, includedParameters: [.appVersion]) + case .bookmarksStructureNotRecovered: + DailyPixel.fire(pixel: .debugBookmarksStructureNotRecovered, includedParameters: [.appVersion]) case .bookmarksStructureBroken(let additionalParams): DailyPixel.fire(pixel: .debugBookmarksInvalidRoots, withAdditionalParameters: additionalParams, @@ -56,19 +56,51 @@ struct BookmarksDatabaseSetup { includedParameters: [.appVersion]) } } + } + + func loadStoreAndMigrate(bookmarksDatabase: CoreDataStoring, + formFactorFavoritesMigrator: BookmarkFormFactorFavoritesMigrating = BookmarkFormFactorFavoritesMigration(), + validator: BookmarksStateValidation = Self.makeValidator()) -> Result { + + let oldFavoritesOrder: [String]? + do { + oldFavoritesOrder = try formFactorFavoritesMigrator.getFavoritesOrderFromPreV4Model( + dbContainerLocation: BookmarksDatabase.defaultDBLocation, + dbFileURL: BookmarksDatabase.defaultDBFileURL + ) + } catch { + return .failure(error) + } var migrationHappened = false + var loadError: Error? bookmarksDatabase.loadStore { context, error in - guard let context = assertContext(context, error, crashOnError) else { return } + guard let context = context, error == nil else { + loadError = error + return + } - validator.validateInitialState(context: context) + // Perform pre-setup/migration validation + let isMissingStructure = !validator.validateInitialState(context: context, + validationError: .bookmarksStructureLost) self.migrateFromLegacyCoreDataStorageIfNeeded(context) migrationHappened = self.migrateToFormFactorSpecificFavorites(context, oldFavoritesOrder) - // Add new migrations and set migrationHappened flag here. Only the last migration is relevant. + + if isMissingStructure { + _ = validator.validateInitialState(context: context, + validationError: .bookmarksStructureNotRecovered) + } + + // Add new migrations and set migrationHappened flag above this comment. Only the last migration is relevant. // Also bump the int passed to the assert function below. } + if let loadError { + return .failure(loadError) + } + + // Perform post-setup validation let contextForValidation = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) contextForValidation.performAndWait { validator.validateBookmarksStructure(context: contextForValidation) @@ -83,7 +115,7 @@ struct BookmarksDatabaseSetup { } } - return migrationHappened + return .success } private func repairDeletedFlag(context: NSManagedObjectContext) { @@ -125,57 +157,37 @@ struct BookmarksDatabaseSetup { LegacyBookmarksStoreMigration.migrate(from: legacyStorage, to: context) legacyStorage?.removeStore() } - - private func assertContext(_ context: NSManagedObjectContext?, _ error: Error?, _ crashOnError: Bool) -> NSManagedObjectContext? { - guard let context = context else { - if let error = error { - Pixel.fire(pixel: .bookmarksCouldNotLoadDatabase, - error: error) - } else { - Pixel.fire(pixel: .bookmarksCouldNotLoadDatabase) - } +} - if !crashOnError { - return nil - } else { - Thread.sleep(forTimeInterval: 1) - fatalError("Could not create Bookmarks database stack: \(error?.localizedDescription ?? "err")") - } - } - return context +class BookmarksMigrationAssertion { + + enum Error: Swift.Error { + case unexpectedMigration } - private func createErrorHandling() -> EventMapping { - return EventMapping { _, error, _, _ in - if let error = error { - Pixel.fire(pixel: .bookmarksCouldNotLoadDatabase, - error: error) - } else { - Pixel.fire(pixel: .bookmarksCouldNotLoadDatabase) - } + let store: KeyValueStoring - if !crashOnError { - return - } else { - Thread.sleep(forTimeInterval: 1) - fatalError("Could not create Bookmarks database stack: \(error?.localizedDescription ?? "err")") - } + init(store: KeyValueStoring = UserDefaults.app) { + self.store = store + } + + var lastGoodVersion: String? { + get { + return store.object(forKey: UserDefaultsWrapper.Key.bookmarksLastGoodVersion.rawValue) as? String + } + set { + store.set(newValue, forKey: UserDefaultsWrapper.Key.bookmarksLastGoodVersion.rawValue) } } - -} -class BookmarksMigrationAssertion { - - enum Error: Swift.Error { - case unexpectedMigration + var migrationVersion: Int { + get { + return (store.object(forKey: UserDefaultsWrapper.Key.bookmarksMigrationVersion.rawValue) as? Int) ?? 0 + } + set { + store.set(newValue, forKey: UserDefaultsWrapper.Key.bookmarksMigrationVersion.rawValue) + } } - - @UserDefaultsWrapper(key: .bookmarksLastGoodVersion, defaultValue: nil) - var lastGoodVersion: String? - - @UserDefaultsWrapper(key: .bookmarksMigrationVersion, defaultValue: 0) - var migrationVersion: Int // Wanted to use assertions here, but that's trick to test. func assert(migrationVersion: Int) throws { diff --git a/DuckDuckGoTests/BookmarksDatabaseSetupTests.swift b/DuckDuckGoTests/BookmarksDatabaseSetupTests.swift new file mode 100644 index 0000000000..b7ef842a95 --- /dev/null +++ b/DuckDuckGoTests/BookmarksDatabaseSetupTests.swift @@ -0,0 +1,253 @@ +// +// BookmarksDatabaseSetupTests.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +import Foundation +import XCTest +import Persistence +import CoreData +import Bookmarks +@testable import DuckDuckGo +@testable import Core +import TestUtils + +class DummyCoreDataStoreMock: CoreDataStoring { + + init() { + model = NSManagedObjectModel() + coordinator = NSPersistentStoreCoordinator() + } + + var isDatabaseFileInitialized: Bool = false + var model: NSManagedObjectModel + var coordinator: NSPersistentStoreCoordinator + + var onLoadStore: ((NSManagedObjectContext?, Error?) -> Void) -> Void = { _ in } + func loadStore(completion: @escaping (NSManagedObjectContext?, Error?) -> Void) { + onLoadStore(completion) + } + + var onMakeContext = { } + func makeContext(concurrencyType: NSManagedObjectContextConcurrencyType, name: String?) -> NSManagedObjectContext { + onMakeContext() + + return NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + } +} + +class CoreDataStoreMock: CoreDataStoring { + + let db: CoreDataDatabase + init(db: CoreDataDatabase) { + self.db = db + } + + var isDatabaseFileInitialized: Bool = false + var model: NSManagedObjectModel { + db.model + } + var coordinator: NSPersistentStoreCoordinator { + db.coordinator + } + + var onLoadStore: () -> Void = { } + func loadStore(completion: @escaping (NSManagedObjectContext?, Error?) -> Void) { + onLoadStore() + db.loadStore(completion: completion) + } + + var onMakeContext = { } + func makeContext(concurrencyType: NSManagedObjectContextConcurrencyType, name: String?) -> NSManagedObjectContext { + onMakeContext() + return db.makeContext(concurrencyType: concurrencyType, name: name) + } +} + +class FormFactorMigratingMock: BookmarkFormFactorFavoritesMigrating { + + var onGetFavs: () throws -> [String]? = { return nil } + func getFavoritesOrderFromPreV4Model(dbContainerLocation: URL, dbFileURL: URL) throws -> [String]? { + try onGetFavs() + } +} + +class BookmarksStateValidationMock: BookmarksStateValidation { + + var onValidateInitialState: () -> Bool = { return true } + func validateInitialState(context: NSManagedObjectContext, validationError: Core.BookmarksStateValidator.ValidationError) -> Bool { + onValidateInitialState() + } + + var onValidateBookmarksStructure: () -> Void = { } + func validateBookmarksStructure(context: NSManagedObjectContext) { + onValidateBookmarksStructure() + } +} + +class BookmarksDatabaseSetupTests: XCTestCase { + + let validatorMock = BookmarksStateValidationMock() + let ffMock = FormFactorMigratingMock() + + func setUpValidBookmarksDatabase() -> CoreDataDatabase? { + let 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 nil + } + return CoreDataDatabase(name: type(of: self).description(), + containerLocation: location, + model: model) + } + + func testWhenDatabaseLoadsCorrectlyThenValidationIsPerformed() { + + guard let bookmarksDB = setUpValidBookmarksDatabase() else { + XCTFail("Could not create DB") + return + } + + let dbMock = CoreDataStoreMock(db: bookmarksDB) + + let storeLoaded = expectation(description: "store loaded") + dbMock.onLoadStore = { + storeLoaded.fulfill() + } + + // Used in Validation + let contextPrepared = expectation(description: "context loaded") + dbMock.onMakeContext = { + contextPrepared.fulfill() + } + + let favsObtained = expectation(description: "Favorites queried") + ffMock.onGetFavs = { + favsObtained.fulfill() + return nil + } + + let initialValidation = expectation(description: "Initial validation") + validatorMock.onValidateInitialState = { + initialValidation.fulfill() + return true + } + + let structureValidation = expectation(description: "Structure validation") + validatorMock.onValidateBookmarksStructure = { + structureValidation.fulfill() + } + + let setup = BookmarksDatabaseSetup(migrationAssertion: BookmarksMigrationAssertion(store: MockKeyValueStore())) + + switch setup.loadStoreAndMigrate(bookmarksDatabase: dbMock, + formFactorFavoritesMigrator: ffMock, + validator: validatorMock) { + case .success: + break + case .failure(let error): + XCTFail("Unexpected error: \(error)") + } + + wait(for: [storeLoaded, contextPrepared, favsObtained, initialValidation, structureValidation], timeout: 5) + } + + func testWhenFetchingOldStateFailsThenErrorIsReturned() { + + let dbMock = DummyCoreDataStoreMock() + dbMock.onLoadStore = { _ in + XCTFail("Store should not be loaded") + } + dbMock.onMakeContext = { + XCTFail("Context should not be requested") + } + + let favsObtained = expectation(description: "Favorites queried") + ffMock.onGetFavs = { + favsObtained.fulfill() + throw BookmarksModelError.bookmarkFolderExpected + } + + validatorMock.onValidateInitialState = { + XCTFail("Validation should not be called") + return true + } + + validatorMock.onValidateBookmarksStructure = { + XCTFail("Validation should not be called") + } + + let setup = BookmarksDatabaseSetup(migrationAssertion: BookmarksMigrationAssertion(store: MockKeyValueStore())) + + switch setup.loadStoreAndMigrate(bookmarksDatabase: dbMock, + formFactorFavoritesMigrator: ffMock, + validator: validatorMock) { + case .success: + XCTFail("Unexpected") + case .failure(let error): + XCTAssertEqual(error as? BookmarksModelError, BookmarksModelError.bookmarkFolderExpected) + } + + wait(for: [favsObtained], timeout: 5) + } + + func testWhenLoadingStoreFailsThenErrorIsReturned() { + + let dbMock = DummyCoreDataStoreMock() + + let onLoadStore = expectation(description: "Favorites queried") + dbMock.onLoadStore = { completion in + onLoadStore.fulfill() + completion(nil, BookmarksModelError.bookmarkFolderExpected) + } + dbMock.onMakeContext = { + XCTFail("Context should not be requested") + } + + let favsObtained = expectation(description: "Favorites queried") + ffMock.onGetFavs = { + favsObtained.fulfill() + return nil + } + + validatorMock.onValidateInitialState = { + XCTFail("Validation should not be called") + return true + } + + validatorMock.onValidateBookmarksStructure = { + XCTFail("Validation should not be called") + } + + let setup = BookmarksDatabaseSetup(migrationAssertion: BookmarksMigrationAssertion(store: MockKeyValueStore())) + + switch setup.loadStoreAndMigrate(bookmarksDatabase: dbMock, + formFactorFavoritesMigrator: ffMock, + validator: validatorMock) { + case .success: + XCTFail("Unexpected") + case .failure(let error): + XCTAssertEqual(error as? BookmarksModelError, BookmarksModelError.bookmarkFolderExpected) + } + + wait(for: [onLoadStore, favsObtained], timeout: 5) + } + +} diff --git a/DuckDuckGoTests/BookmarksStateValidationTests.swift b/DuckDuckGoTests/BookmarksStateValidationTests.swift index d4827aef31..75d3ff5365 100644 --- a/DuckDuckGoTests/BookmarksStateValidationTests.swift +++ b/DuckDuckGoTests/BookmarksStateValidationTests.swift @@ -62,28 +62,28 @@ class BookmarksStateValidationTests: XCTestCase { BookmarkUtils.prepareFoldersStructure(in: context) } - let validator = BookmarksStateValidation(keyValueStore: mockKeyValueStore) { error in + let validator = BookmarksStateValidator(keyValueStore: mockKeyValueStore) { error in XCTFail("Did not expect error: \(error)") } - mockKeyValueStore.set(true, forKey: BookmarksStateValidation.Constants.bookmarksDBIsInitialized) + mockKeyValueStore.set(true, forKey: BookmarksStateValidator.Constants.bookmarksDBIsInitialized) let testContext = dbStack.makeContext(concurrencyType: .privateQueueConcurrencyType) testContext.performAndWait { - validator.validateInitialState(context: testContext) + XCTAssertTrue(validator.validateInitialState(context: testContext, validationError: .bookmarksStructureLost)) validator.validateBookmarksStructure(context: testContext) } } func testWhenDatabaseIsEmptyButItHasNotBeenInitiatedThenThereIsNoError() { - let validator = BookmarksStateValidation(keyValueStore: mockKeyValueStore) { error in + let validator = BookmarksStateValidator(keyValueStore: mockKeyValueStore) { error in XCTFail("Did not expect error: \(error)") } let testContext = dbStack.makeContext(concurrencyType: .privateQueueConcurrencyType) testContext.performAndWait { - validator.validateInitialState(context: testContext) + XCTAssertTrue(validator.validateInitialState(context: testContext, validationError: .bookmarksStructureLost)) } } @@ -92,7 +92,7 @@ class BookmarksStateValidationTests: XCTestCase { let expectation1 = expectation(description: "Lost structure Error raised") let expectation2 = expectation(description: "Broken structure Error raised") - let validator = BookmarksStateValidation(keyValueStore: mockKeyValueStore) { error in + let validator = BookmarksStateValidator(keyValueStore: mockKeyValueStore) { error in switch error { case .bookmarksStructureLost: expectation1.fulfill() @@ -103,11 +103,11 @@ class BookmarksStateValidationTests: XCTestCase { } } - mockKeyValueStore.set(true, forKey: BookmarksStateValidation.Constants.bookmarksDBIsInitialized) + mockKeyValueStore.set(true, forKey: BookmarksStateValidator.Constants.bookmarksDBIsInitialized) let testContext = dbStack.makeContext(concurrencyType: .privateQueueConcurrencyType) testContext.performAndWait { - validator.validateInitialState(context: testContext) + XCTAssertFalse(validator.validateInitialState(context: testContext, validationError: .bookmarksStructureLost)) validator.validateBookmarksStructure(context: testContext) } @@ -130,7 +130,7 @@ class BookmarksStateValidationTests: XCTestCase { let expectation = expectation(description: "Broken structure Error raised") - let validator = BookmarksStateValidation(keyValueStore: mockKeyValueStore) { error in + let validator = BookmarksStateValidator(keyValueStore: mockKeyValueStore) { error in switch error { case .bookmarksStructureBroken(let errorInfo): expectation.fulfill() @@ -144,11 +144,11 @@ class BookmarksStateValidationTests: XCTestCase { } } - mockKeyValueStore.set(true, forKey: BookmarksStateValidation.Constants.bookmarksDBIsInitialized) + mockKeyValueStore.set(true, forKey: BookmarksStateValidator.Constants.bookmarksDBIsInitialized) let testContext = dbStack.makeContext(concurrencyType: .privateQueueConcurrencyType) testContext.performAndWait { - validator.validateInitialState(context: testContext) + XCTAssertTrue(validator.validateInitialState(context: testContext, validationError: .bookmarksStructureLost)) validator.validateBookmarksStructure(context: testContext) } @@ -170,7 +170,7 @@ class BookmarksStateValidationTests: XCTestCase { let expectation = expectation(description: "Broken structure Error raised") - let validator = BookmarksStateValidation(keyValueStore: mockKeyValueStore) { error in + let validator = BookmarksStateValidator(keyValueStore: mockKeyValueStore) { error in switch error { case .bookmarksStructureBroken(let errorInfo): expectation.fulfill() @@ -184,11 +184,11 @@ class BookmarksStateValidationTests: XCTestCase { } } - mockKeyValueStore.set(true, forKey: BookmarksStateValidation.Constants.bookmarksDBIsInitialized) + mockKeyValueStore.set(true, forKey: BookmarksStateValidator.Constants.bookmarksDBIsInitialized) let testContext = dbStack.makeContext(concurrencyType: .privateQueueConcurrencyType) testContext.performAndWait { - validator.validateInitialState(context: testContext) + XCTAssertTrue(validator.validateInitialState(context: testContext, validationError: .bookmarksStructureLost)) validator.validateBookmarksStructure(context: testContext) }