diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 249fec43c..bb789b43a 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -133,6 +133,12 @@ 473A986D2C91E032005A3B94 /* MonitoringLogsError.json in Resources */ = {isa = PBXBuildFile; fileRef = 473A986C2C91E032005A3B94 /* MonitoringLogsError.json */; }; 473A986F2C91E047005A3B94 /* MonitoringLogsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = 473A986E2C91E047005A3B94 /* MonitoringLogsTypeError.json */; }; 473A98712C91E629005A3B94 /* MonitoringLogsElementsMixedError.json in Resources */ = {isa = PBXBuildFile; fileRef = 473A98702C91E629005A3B94 /* MonitoringLogsElementsMixedError.json */; }; + 4741425C2E8A681600839AD8 /* StubDatabaseLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741425B2E8A681600839AD8 /* StubDatabaseLoader.swift */; }; + 4741425E2E8A688300839AD8 /* DataBaseLoading_StubDatabaseLoaderContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741425D2E8A688300839AD8 /* DataBaseLoading_StubDatabaseLoaderContractTests.swift */; }; + 474142602E8A692800839AD8 /* NoopDatabaseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741425F2E8A692800839AD8 /* NoopDatabaseRepository.swift */; }; + 474142622E8AAC8900839AD8 /* DatabaseRepository_NoopContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474142612E8AAC8900839AD8 /* DatabaseRepository_NoopContractTests.swift */; }; + 4741DAC42E85C49F00EB2497 /* DatabaseLoaderFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741DAC32E85C49F00EB2497 /* DatabaseLoaderFlowTests.swift */; }; + 4741DAC62E85DC1600EB2497 /* MBDatabaseRepositoryMemoryWarningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741DAC52E85DC1600EB2497 /* MBDatabaseRepositoryMemoryWarningTests.swift */; }; 474230DC2E72236500282764 /* TestContainers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474230DB2E72236500282764 /* TestContainers.swift */; }; 4747708B2C6B838B00C36FC8 /* SharedInternalMethodsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4747708A2C6B838B00C36FC8 /* SharedInternalMethodsTests.swift */; }; 4747708F2C6B93AC00C36FC8 /* MindboxNotificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4747708E2C6B93AC00C36FC8 /* MindboxNotificationServiceTests.swift */; }; @@ -177,6 +183,8 @@ 47C464EF2E0EB88C00F50B21 /* RemoveBackgroundTaskDataMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C464EE2E0EB88C00F50B21 /* RemoveBackgroundTaskDataMigrationTests.swift */; }; 47D0BC2C2E093F8A00182DB2 /* ClockAndMockClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47D0BC2B2E093F8A00182DB2 /* ClockAndMockClock.swift */; }; 47D63E2B2C2EABDF0055E7D8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F3EC93BA2AF105DA0030D107 /* PrivacyInfo.xcprivacy */; }; + 47DCB76A2E82CEA90024FCC1 /* DatabaseRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47DCB7692E82CEA90024FCC1 /* DatabaseRepositoryProtocol.swift */; }; + 47DF1FB02E7D6F90009BC4A0 /* DatabaseLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47DF1FAF2E7D6F90009BC4A0 /* DatabaseLoaderProtocol.swift */; }; 47EFBB2F2CB92B240023A4B9 /* SegmentationCheckResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B4F9DEE28D08897002C9CF0 /* SegmentationCheckResponse.swift */; }; 47FDF0BA2C5BDAB80051F08C /* MigrationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FDF0B92C5BDAB80051F08C /* MigrationManagerProtocol.swift */; }; 47FDF0BC2C5BE8BB0051F08C /* MigrationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FDF0BB2C5BE8BB0051F08C /* MigrationProtocol.swift */; }; @@ -761,6 +769,12 @@ 473A986C2C91E032005A3B94 /* MonitoringLogsError.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = MonitoringLogsError.json; sourceTree = ""; }; 473A986E2C91E047005A3B94 /* MonitoringLogsTypeError.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = MonitoringLogsTypeError.json; sourceTree = ""; }; 473A98702C91E629005A3B94 /* MonitoringLogsElementsMixedError.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = MonitoringLogsElementsMixedError.json; sourceTree = ""; }; + 4741425B2E8A681600839AD8 /* StubDatabaseLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubDatabaseLoader.swift; sourceTree = ""; }; + 4741425D2E8A688300839AD8 /* DataBaseLoading_StubDatabaseLoaderContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBaseLoading_StubDatabaseLoaderContractTests.swift; sourceTree = ""; }; + 4741425F2E8A692800839AD8 /* NoopDatabaseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoopDatabaseRepository.swift; sourceTree = ""; }; + 474142612E8AAC8900839AD8 /* DatabaseRepository_NoopContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRepository_NoopContractTests.swift; sourceTree = ""; }; + 4741DAC32E85C49F00EB2497 /* DatabaseLoaderFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseLoaderFlowTests.swift; sourceTree = ""; }; + 4741DAC52E85DC1600EB2497 /* MBDatabaseRepositoryMemoryWarningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBDatabaseRepositoryMemoryWarningTests.swift; sourceTree = ""; }; 474230DB2E72236500282764 /* TestContainers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContainers.swift; sourceTree = ""; }; 4747708A2C6B838B00C36FC8 /* SharedInternalMethodsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedInternalMethodsTests.swift; sourceTree = ""; }; 4747708E2C6B93AC00C36FC8 /* MindboxNotificationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxNotificationServiceTests.swift; sourceTree = ""; }; @@ -804,6 +818,8 @@ 47C38A302E05920B00D5A2FE /* MockDatabaseRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDatabaseRepositoryTests.swift; sourceTree = ""; }; 47C464EE2E0EB88C00F50B21 /* RemoveBackgroundTaskDataMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveBackgroundTaskDataMigrationTests.swift; sourceTree = ""; }; 47D0BC2B2E093F8A00182DB2 /* ClockAndMockClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClockAndMockClock.swift; sourceTree = ""; }; + 47DCB7692E82CEA90024FCC1 /* DatabaseRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRepositoryProtocol.swift; sourceTree = ""; }; + 47DF1FAF2E7D6F90009BC4A0 /* DatabaseLoaderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseLoaderProtocol.swift; sourceTree = ""; }; 47FDF0B92C5BDAB80051F08C /* MigrationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationManagerProtocol.swift; sourceTree = ""; }; 47FDF0BB2C5BE8BB0051F08C /* MigrationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationProtocol.swift; sourceTree = ""; }; 6F1EAA15266A670E007A335B /* ProductListItemsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListItemsResponse.swift; sourceTree = ""; }; @@ -1779,6 +1795,27 @@ path = ABTestsJsonStubs; sourceTree = ""; }; + 476C2DB02E8BE725000EFF86 /* DatabaseRepository */ = { + isa = PBXGroup; + children = ( + 84DEE8AC25CC036A00C98CC7 /* MBDatabaseRepository.swift */, + 4741425F2E8A692800839AD8 /* NoopDatabaseRepository.swift */, + 47DCB7692E82CEA90024FCC1 /* DatabaseRepositoryProtocol.swift */, + ); + path = DatabaseRepository; + sourceTree = ""; + }; + 476C2DB12E8BE75F000EFF86 /* DatabaseLoader */ = { + isa = PBXGroup; + children = ( + 84DEE8B025CC042A00C98CC7 /* MBDatabaseError.swift */, + 8410681225ECDC73004701C2 /* DatabaseLoader.swift */, + 4741425B2E8A681600839AD8 /* StubDatabaseLoader.swift */, + 47DF1FAF2E7D6F90009BC4A0 /* DatabaseLoaderProtocol.swift */, + ); + path = DatabaseLoader; + sourceTree = ""; + }; 47B90E2D2C625F8A00BD93E7 /* TestsMigrations */ = { isa = PBXGroup; children = ( @@ -1844,8 +1881,12 @@ isa = PBXGroup; children = ( 840C388425CD169400D50183 /* DatabaseRepositoryTestCase.swift */, + 474142612E8AAC8900839AD8 /* DatabaseRepository_NoopContractTests.swift */, 84B09FB32611C74400B0A06E /* MockDatabaseRepository.swift */, + 4741DAC52E85DC1600EB2497 /* MBDatabaseRepositoryMemoryWarningTests.swift */, 84E1D6A8261D8D54002BF03A /* DatabaseLoaderTest.swift */, + 4741425D2E8A688300839AD8 /* DataBaseLoading_StubDatabaseLoaderContractTests.swift */, + 4741DAC32E85C49F00EB2497 /* DatabaseLoaderFlowTests.swift */, 47C38A302E05920B00D5A2FE /* MockDatabaseRepositoryTests.swift */, ); path = Database; @@ -2094,11 +2135,10 @@ 84DEE8A625CC02AF00C98CC7 /* Database */ = { isa = PBXGroup; children = ( + 476C2DB12E8BE75F000EFF86 /* DatabaseLoader */, + 476C2DB02E8BE725000EFF86 /* DatabaseRepository */, 840C386525CC1A5300D50183 /* Entities */, 84DEE8A725CC031200C98CC7 /* MBDatabase.xcdatamodeld */, - 84DEE8AC25CC036A00C98CC7 /* MBDatabaseRepository.swift */, - 84DEE8B025CC042A00C98CC7 /* MBDatabaseError.swift */, - 8410681225ECDC73004701C2 /* DatabaseLoader.swift */, ); path = Database; sourceTree = ""; @@ -3691,6 +3731,7 @@ 84A312B725DA64C60096A017 /* BGTaskManager.swift in Sources */, F331DD1C2A84B5EF00222120 /* ImageContentBackgroundLayer.swift in Sources */, F3A8B9A52A3A6F2800E9C055 /* MonitoringModel.swift in Sources */, + 4741425C2E8A681600839AD8 /* StubDatabaseLoader.swift in Sources */, F33087662C37590600F8DF10 /* InjectInappTools.swift in Sources */, 334F3AE8264C199900A6AC00 /* ItemRequest.swift in Sources */, 84CC799325CACF0C00C062BD /* EventRepository.swift in Sources */, @@ -3872,6 +3913,7 @@ A15D701629AF810E007131E7 /* SDKLogsRequest.swift in Sources */, F31A947C2BC69E3900E6C978 /* PeriodicFrequency.swift in Sources */, 33072F362664C4D7001F1AB2 /* RecommendationRequest.swift in Sources */, + 47DCB76A2E82CEA90024FCC1 /* DatabaseRepositoryProtocol.swift in Sources */, 84DC49CC25D1832300D5D758 /* EventWrapper.swift in Sources */, F39116FA2AA9B32E00852298 /* LayersFilter.swift in Sources */, F31A947E2BC6A00D00E6C978 /* OnceFrequency.swift in Sources */, @@ -3888,6 +3930,7 @@ F33608262A8CC94A00C7C9B7 /* SnackbarViewController.swift in Sources */, 3328FE4226303F2F000A30D0 /* String+Regex.swift in Sources */, F3A4EFDC2D5224C700DB96A8 /* SlidingExpirationModel.swift in Sources */, + 47DF1FB02E7D6F90009BC4A0 /* DatabaseLoaderProtocol.swift in Sources */, F31A94802BC7E61800E6C978 /* InappFrequencyValidator.swift in Sources */, 33072F322664C357001F1AB2 /* ProductListResponse.swift in Sources */, 9B24FAB128C74BD200F10B5D /* InAppConfigurationManager.swift in Sources */, @@ -3929,6 +3972,7 @@ A184654329C3102A00E64780 /* CategoryIDTargeting.swift in Sources */, F33608282A8CD17E00C7C9B7 /* SnackbarPresentationStrategy.swift in Sources */, 330D8CCB26579521005106D5 /* DiscountTypeRequest.swift in Sources */, + 474142602E8A692800839AD8 /* NoopDatabaseRepository.swift in Sources */, F349756A2DEF2C8400BEC667 /* InappTrackingService.swift in Sources */, F382F2112BAC6AD100BC97FF /* UNAuthorizationStatus+Extensions.swift in Sources */, F3FEEA9B2C25AC68000E9D0F /* MBContainer.swift in Sources */, @@ -4002,6 +4046,7 @@ 9B9C9538292111A700BB29DA /* MockUUIDDebugService.swift in Sources */, 47A4FA782E73741700569870 /* LoggerDatabaseLoaderTests.swift in Sources */, 84B625F025C98B1200AB6228 /* ValidatorsTestCase.swift in Sources */, + 4741425E2E8A688300839AD8 /* DataBaseLoading_StubDatabaseLoaderContractTests.swift in Sources */, 847F580325C88BBF00147A9A /* HTTPMethod.swift in Sources */, F351F1C22CE5F23A0053423E /* InappMapperTests.swift in Sources */, 47C464EF2E0EB88C00F50B21 /* RemoveBackgroundTaskDataMigrationTests.swift in Sources */, @@ -4019,6 +4064,7 @@ 9B52570728D1AF880029B1BC /* InAppPresentationManagerMock.swift in Sources */, D2F7E2482BADB9EF00B24BB8 /* UserVisitManagerTests.swift in Sources */, BBAAC17C2BB2FC9100E1E25E /* MockEvent.swift in Sources */, + 474142622E8AAC8900839AD8 /* DatabaseRepository_NoopContractTests.swift in Sources */, 47D0BC2C2E093F8A00182DB2 /* ClockAndMockClock.swift in Sources */, 84B09FB42611C74400B0A06E /* MockDatabaseRepository.swift in Sources */, 473A98262C918C3A005A3B94 /* ConfigParsingTests.swift in Sources */, @@ -4027,6 +4073,7 @@ 473A98332C91A4B3005A3B94 /* MonitoringConfigParsingTests.swift in Sources */, F3FEEAA62C25CB2E000E9D0F /* XCTestCase+Extensions.swift in Sources */, 84ECB42E25D27EF100DA8AC9 /* MockUNAuthorizationStatusProvider.swift in Sources */, + 4741DAC62E85DC1600EB2497 /* MBDatabaseRepositoryMemoryWarningTests.swift in Sources */, D2F7E24C2BADC4CA00B24BB8 /* MockSessionManager.swift in Sources */, F31470842B96355600E01E5C /* MockInAppConfigurationDataFacade.swift in Sources */, 47B90E312C626B9300BD93E7 /* TestProtocolMigrations.swift in Sources */, @@ -4043,6 +4090,7 @@ A154E334299E110E00F8F074 /* EventRepositoryMock.swift in Sources */, 31ED2DEC25C444C400301FAD /* MBConfigurationTestCase.swift in Sources */, F3D925AD2A1236F400135C87 /* URLSessionImageDownloaderTests.swift in Sources */, + 4741DAC42E85C49F00EB2497 /* DatabaseLoaderFlowTests.swift in Sources */, 473A982D2C91A38C005A3B94 /* SettingsConfigParsingTests.swift in Sources */, F3A8B9A02A3A52F400E9C055 /* ABTestValidatorTests.swift in Sources */, 47C38A312E05920B00D5A2FE /* MockDatabaseRepositoryTests.swift in Sources */, diff --git a/Mindbox/ClickNotificationManager/ClickNotificationManager.swift b/Mindbox/ClickNotificationManager/ClickNotificationManager.swift index 9c0a2ee9f..3c567800c 100644 --- a/Mindbox/ClickNotificationManager/ClickNotificationManager.swift +++ b/Mindbox/ClickNotificationManager/ClickNotificationManager.swift @@ -11,10 +11,10 @@ import UserNotifications final class ClickNotificationManager { - private let databaseRepository: MBDatabaseRepository + private let databaseRepository: DatabaseRepositoryProtocol init( - databaseRepository: MBDatabaseRepository + databaseRepository: DatabaseRepositoryProtocol ) { self.databaseRepository = databaseRepository } diff --git a/Mindbox/CoreController/CoreController.swift b/Mindbox/CoreController/CoreController.swift index a8fe68b81..84bf83538 100644 --- a/Mindbox/CoreController/CoreController.swift +++ b/Mindbox/CoreController/CoreController.swift @@ -14,7 +14,7 @@ import MindboxCommon final class CoreController { private let persistenceStorage: PersistenceStorage private let utilitiesFetcher: UtilitiesFetcher - private let databaseRepository: MBDatabaseRepository + private let databaseRepository: DatabaseRepositoryProtocol private let guaranteedDeliveryManager: GuaranteedDeliveryManager private let uuidDebugService: UUIDDebugService private var configValidation = ConfigValidation() @@ -239,7 +239,7 @@ final class CoreController { init( persistenceStorage: PersistenceStorage, utilitiesFetcher: UtilitiesFetcher, - databaseRepository: MBDatabaseRepository, + databaseRepository: DatabaseRepositoryProtocol, guaranteedDeliveryManager: GuaranteedDeliveryManager, sessionManager: SessionManager, inAppMessagesManager: InAppCoreManagerProtocol, diff --git a/Mindbox/DI/Injections/InjectCore.swift b/Mindbox/DI/Injections/InjectCore.swift index 90bec4e0f..871eaeec9 100644 --- a/Mindbox/DI/Injections/InjectCore.swift +++ b/Mindbox/DI/Injections/InjectCore.swift @@ -13,7 +13,7 @@ extension MBContainer { register(CoreController.self) { CoreController(persistenceStorage: DI.injectOrFail(PersistenceStorage.self), utilitiesFetcher: DI.injectOrFail(UtilitiesFetcher.self), - databaseRepository: DI.injectOrFail(MBDatabaseRepository.self), + databaseRepository: DI.injectOrFail(DatabaseRepositoryProtocol.self), guaranteedDeliveryManager: DI.injectOrFail(GuaranteedDeliveryManager.self), sessionManager: DI.injectOrFail(SessionManager.self), inAppMessagesManager: DI.injectOrFail(InAppCoreManagerProtocol.self), @@ -23,7 +23,7 @@ extension MBContainer { register(GuaranteedDeliveryManager.self) { let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) - let databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + let databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) let eventRepository = DI.injectOrFail(EventRepository.self) return GuaranteedDeliveryManager( persistenceStorage: persistenceStorage, diff --git a/Mindbox/DI/Injections/InjectInappTools.swift b/Mindbox/DI/Injections/InjectInappTools.swift index 5fe1106a8..d614c777a 100644 --- a/Mindbox/DI/Injections/InjectInappTools.swift +++ b/Mindbox/DI/Injections/InjectInappTools.swift @@ -80,7 +80,7 @@ extension MBContainer { func registerInappPresentation() -> Self { register(InAppMessagesTracker.self) { - let databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + let databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) return InAppMessagesTracker(databaseRepository: databaseRepository) } diff --git a/Mindbox/DI/Injections/InjectReplaceable.swift b/Mindbox/DI/Injections/InjectReplaceable.swift index ed836996e..c25b19fb5 100644 --- a/Mindbox/DI/Injections/InjectReplaceable.swift +++ b/Mindbox/DI/Injections/InjectReplaceable.swift @@ -30,19 +30,22 @@ extension MBContainer { register(PersistenceStorage.self) { let utilitiesFetcher = DI.injectOrFail(UtilitiesFetcher.self) guard let defaults = UserDefaults(suiteName: utilitiesFetcher.applicationGroupIdentifier) else { - fatalError("Failed to create UserDefaults with suite name: \(utilitiesFetcher.applicationGroupIdentifier). Check and set up your AppGroups correctly.") + assertionFailure("Failed to create UserDefaults with suite name: \(utilitiesFetcher.applicationGroupIdentifier). Check and set up your AppGroups correctly.") + return MBPersistenceStorage(defaults: UserDefaults.standard) } return MBPersistenceStorage(defaults: defaults) } + + register(DatabaseRepositoryProtocol.self) { + let loader = DI.injectOrFail(DatabaseLoaderProtocol.self) - register(MBDatabaseRepository.self) { - let databaseLoader = DI.injectOrFail(DataBaseLoader.self) - - guard let persistentContainer = try? databaseLoader.loadPersistentContainer(), - let dbRepository = try? MBDatabaseRepository(persistentContainer: persistentContainer) else { - fatalError("Failed to create MBDatabaseRepository") + do { + let container = try loader.loadPersistentContainer() + return try MBDatabaseRepository(persistentContainer: container) + } catch { + assertionFailure("Failed to create MBDatabaseRepository: \(error)") + return NoopDatabaseRepository() } - return dbRepository } register(ImageDownloadServiceProtocol.self, scope: .container) { diff --git a/Mindbox/DI/Injections/InjectUtilities.swift b/Mindbox/DI/Injections/InjectUtilities.swift index 43ef963ac..bda4aec18 100644 --- a/Mindbox/DI/Injections/InjectUtilities.swift +++ b/Mindbox/DI/Injections/InjectUtilities.swift @@ -36,16 +36,19 @@ extension MBContainer { let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) return InAppTargetingChecker(persistenceStorage: persistenceStorage) } - - register(DataBaseLoader.self) { + + register(DatabaseLoaderProtocol.self) { let utilitiesFetcher = DI.injectOrFail(UtilitiesFetcher.self) - - guard let dbLoader = try? DataBaseLoader(applicationGroupIdentifier: utilitiesFetcher.applicationGroupIdentifier) else { - fatalError("Failed to create DataBaseLoader") + + do { + let dbLoader = try DatabaseLoader(applicationGroupIdentifier: utilitiesFetcher.applicationGroupIdentifier) + return dbLoader + } catch { + assertionFailure(" Failed to create DatabaseLoader: \(error.localizedDescription). Falling back to StubDBLoader - app in production will run in degraded mode (no on-disk persistence)") + return StubDatabaseLoader() } - return dbLoader } - + register(VariantImageUrlExtractorServiceProtocol.self, scope: .transient) { VariantImageUrlExtractorService() } @@ -70,7 +73,7 @@ extension MBContainer { } register(TrackVisitManagerProtocol.self) { - let databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + let databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) let inappSessionManger = DI.injectOrFail(InappSessionManagerProtocol.self) return TrackVisitManager(databaseRepository: databaseRepository, inappSessionManager: inappSessionManger) } @@ -81,7 +84,7 @@ extension MBContainer { } register(ClickNotificationManager.self) { - let databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + let databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) return ClickNotificationManager(databaseRepository: databaseRepository) } diff --git a/Mindbox/Database/DatabaseLoader.swift b/Mindbox/Database/DatabaseLoader.swift deleted file mode 100644 index 81244f474..000000000 --- a/Mindbox/Database/DatabaseLoader.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// DatabaseLoader.swift -// Mindbox -// -// Created by Maksim Kazachkov on 01.03.2021. -// Copyright © 2021 Mindbox. All rights reserved. -// - -import Foundation -import CoreData -import MindboxLogger - -class DataBaseLoader { - - private let persistentStoreDescriptions: [NSPersistentStoreDescription]? - private let persistentContainer: NSPersistentContainer - var persistentStoreDescription: NSPersistentStoreDescription? - - var loadPersistentStoresError: Error? - var persistentStoreURL: URL? - - init(persistentStoreDescriptions: [NSPersistentStoreDescription]? = nil, applicationGroupIdentifier: String? = nil) throws { - MBPersistentContainer.applicationGroupIdentifier = applicationGroupIdentifier - let momdName = Constants.Database.mombName - var modelURL: URL? - - #if SWIFT_PACKAGE - if let modelURLSwift = Bundle.module.url(forResource: momdName, withExtension: "momd") { - modelURL = modelURLSwift - } - #else - - if let podBundle = Bundle(for: DataBaseLoader.self).url(forResource: "Mindbox", withExtension: "bundle"), - let modelURLPod = Bundle(url: podBundle)?.url(forResource: momdName, withExtension: "momd") { - modelURL = modelURLPod - } else if let modelURLAdditional = Bundle(for: DataBaseLoader.self).url(forResource: momdName, withExtension: "momd") { - modelURL = modelURLAdditional - } - - #endif - - guard let modelURL = modelURL else { - Logger.common(message: MBDatabaseError.unableCreateDatabaseModel.errorDescription, level: .error, category: .database) - throw MBDatabaseError.unableCreateDatabaseModel - } - - guard let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else { - Logger.common(message: MBDatabaseError.unableCreateManagedObjectModel(with: modelURL).errorDescription, level: .error, category: .database) - throw MBDatabaseError.unableCreateManagedObjectModel(with: modelURL) - } - self.persistentContainer = MBPersistentContainer( - name: momdName, - managedObjectModel: managedObjectModel - ) - - self.persistentStoreDescriptions = persistentStoreDescriptions - if let persistentStoreDescriptions = persistentStoreDescriptions { - persistentContainer.persistentStoreDescriptions = persistentStoreDescriptions - } - persistentContainer.persistentStoreDescriptions.forEach { - $0.setOption(FileProtectionType.none as NSObject, forKey: NSPersistentStoreFileProtectionKey) - $0.shouldMigrateStoreAutomatically = true - $0.shouldInferMappingModelAutomatically = true - } - } - - func loadPersistentContainer() throws -> NSPersistentContainer { - do { - return try loadPersistentStores() - } catch { - do { - try destroy() - return try loadPersistentStores() - } - } - } - - private func loadPersistentStores() throws -> NSPersistentContainer { - persistentContainer.loadPersistentStores { [weak self] persistentStoreDescription, error in - if persistentStoreDescription.url != nil { - Logger.common(message: "[DataBaseLoader] Persistent store URL successfully loaded", level: .info, category: .database) - } else { - Logger.common(message: "[DataBaseLoader] Persistent store URL is missing", level: .error, category: .database) - } - self?.persistentStoreURL = persistentStoreDescription.url - self?.loadPersistentStoresError = error - self?.persistentStoreDescription = persistentStoreDescription - } - if let error = loadPersistentStoresError { - Logger.common(message: "[DataBaseLoader] Failed to load persistent stores: \(error)", level: .error, category: .database) - throw error - } - return persistentContainer - } - - func destroy() throws { - guard let persistentStoreURL = persistentStoreURL else { - Logger.common(message: MBDatabaseError.persistentStoreURLNotFound.errorDescription, level: .error, category: .database) - throw MBDatabaseError.persistentStoreURLNotFound - } - - Logger.common(message: "[DataBaseLoader] Removing database at url: \(persistentStoreURL.absoluteString)", level: .info, category: .database) - - guard FileManager.default.fileExists(atPath: persistentStoreURL.path) else { - Logger.common(message: MBDatabaseError.persistentStoreNotExistsAtURL(path: persistentStoreURL.path).errorDescription, level: .error, category: .database) - throw MBDatabaseError.persistentStoreNotExistsAtURL(path: persistentStoreURL.path) - } - do { - try self.persistentContainer.persistentStoreCoordinator.destroyPersistentStore(at: persistentStoreURL, ofType: "sqlite", options: nil) - Logger.common(message: "[DataBaseLoader] Database has been removed", level: .info, category: .database) - } catch { - Logger.common(message: "[DataBaseLoader] Failed to remove database: \(error.localizedDescription)", level: .error, category: .database) - throw error - } - } -} diff --git a/Mindbox/Database/DatabaseLoader/DatabaseLoader.swift b/Mindbox/Database/DatabaseLoader/DatabaseLoader.swift new file mode 100644 index 000000000..0aa763ef3 --- /dev/null +++ b/Mindbox/Database/DatabaseLoader/DatabaseLoader.swift @@ -0,0 +1,194 @@ +// +// DatabaseLoader.swift +// Mindbox +// +// Created by Maksim Kazachkov on 01.03.2021. +// Copyright © 2021 Mindbox. All rights reserved. +// + +import Foundation +import CoreData +import MindboxLogger + +class DatabaseLoader: DatabaseLoaderProtocol { + + static var metadataKeysToPreserve: [String] { + [ + MBDatabaseRepository.MetadataKey.install.rawValue, + MBDatabaseRepository.MetadataKey.infoUpdate.rawValue, + MBDatabaseRepository.MetadataKey.instanceId.rawValue + ] + } + + private let diskSpaceRepairThreshold: Int64 = 300 * 1024 * 1024 // 300 MB + + var freeSize: Int64 { + let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) + return (attrs?[.systemFreeSize] as? NSNumber)?.int64Value ?? 0 + } + + private let persistentContainer: NSPersistentContainer + + private var storeURL: URL? { + persistentContainer.persistentStoreCoordinator.persistentStores.first?.url + } + + init(persistentStoreDescriptions: [NSPersistentStoreDescription]? = nil, applicationGroupIdentifier: String? = nil) throws { + MBPersistentContainer.applicationGroupIdentifier = applicationGroupIdentifier + + let momdName = Constants.Database.mombName + let modelURL: URL? = { + #if SWIFT_PACKAGE + return Bundle.module.url(forResource: momdName, withExtension: "momd") + #else + if let pod = Bundle(for: DatabaseLoader.self).url(forResource: "Mindbox", withExtension: "bundle"), + let url = Bundle(url: pod)?.url(forResource: momdName, withExtension: "momd") { + return url + } + return Bundle(for: DatabaseLoader.self).url(forResource: momdName, withExtension: "momd") + #endif + }() + + guard let modelURL, let model = NSManagedObjectModel(contentsOf: modelURL) else { + Logger.common(message: MBDatabaseError.unableCreateDatabaseModel.errorDescription, level: .error, category: .database) + throw MBDatabaseError.unableCreateDatabaseModel + } + + let container = MBPersistentContainer(name: momdName, managedObjectModel: model) + if let descs = persistentStoreDescriptions { + container.persistentStoreDescriptions = descs + } + + Self.applyStandardOptions(to: container.persistentStoreDescriptions) + self.persistentContainer = container + } + + private static func applyStandardOptions(to descriptions: [NSPersistentStoreDescription]) { + descriptions.forEach { + $0.shouldAddStoreAsynchronously = false + $0.setOption(FileProtectionType.none as NSObject, forKey: NSPersistentStoreFileProtectionKey) + $0.shouldMigrateStoreAutomatically = true + $0.shouldInferMappingModelAutomatically = true + } + } + + func loadPersistentStores() throws -> NSPersistentContainer { + var capturedError: Error? + + persistentContainer.loadPersistentStores { persistentStoreDescription, error in + if let url = persistentStoreDescription.url { + Logger.common(message: "[DBLoader] Store URL: \(url.path)", level: .info, category: .database) + } else { + Logger.common(message: "[DBLoader] Store URL is nil (in-memory or misconfigured)", level: .info, category: .database) + } + capturedError = error + } + if let capturedError { + Logger.common(message: "[DBLoader] Failed to load persistent stores: \(capturedError)", level: .error, category: .database) + throw capturedError + } + return persistentContainer + } + + func salvageMetadataFromOnDiskStore() -> [String: Any]? { + guard let url = persistentContainer.persistentStoreDescriptions.first?.url else { return nil } + let opts: [AnyHashable: Any] = [NSReadOnlyPersistentStoreOption: true] + do { + let raw = try NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, + at: url, + options: opts + ) + let filtered = raw.filter { Self.metadataKeysToPreserve.contains($0.key) } + return filtered.isEmpty ? nil : filtered + } catch { + Logger.common(message: "[DBLoader] Can't read metadata for salvage (read-only): \(error)", + level: .error, category: .database) + return nil + } + } + + func applyMetadata(_ preserved: [String: Any], to container: NSPersistentContainer) { + let psc = container.persistentStoreCoordinator + guard let store = psc.persistentStores.first, !preserved.isEmpty else { return } + var meta = psc.metadata(for: store) + preserved.forEach { meta[$0.key] = $0.value } + psc.setMetadata(meta, for: store) + Logger.common(message: "[DBLoader] Preserved metadata reapplied: \(Array(preserved.keys))", + level: .info, category: .database) + } + + // MARK: - DataBaseLoading + + func makeInMemoryContainer() throws -> NSPersistentContainer { + let model = persistentContainer.managedObjectModel + let container = MBPersistentContainer(name: persistentContainer.name, managedObjectModel: model) + + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + Self.applyStandardOptions(to: [description]) + container.persistentStoreDescriptions = [description] + + var capturedError: Error? + container.loadPersistentStores { _, error in + capturedError = error + } + if let capturedError { throw capturedError } + return container + } + + func loadPersistentContainer() throws -> NSPersistentContainer { + do { + return try loadPersistentStores() + } catch { + Logger.common(message: "[DBLoader] On-disk load failed: \(error)", + level: .error, category: .database) + } + + let preserved = salvageMetadataFromOnDiskStore() + + let freeSize = freeSize + if freeSize < diskSpaceRepairThreshold { + Logger.common(message: "[DBLoader] Low disk space (\(freeSize)<\(diskSpaceRepairThreshold)); using InMemory without touching store", level: .error, category: .database) + let mem = try makeInMemoryContainer() + if let preserved { applyMetadata(preserved, to: mem) } + return mem + } + + do { + try destroy() + let retried = try loadPersistentStores() + if let preserved { applyMetadata(preserved, to: retried) } + Logger.common(message: "[DBLoader] On-disk retry succeeded after destroy", level: .info, category: .database) + return retried + } catch { + Logger.common(message: "[DBLoader] Repair attempt failed: \(error). Falling back to InMemory.", level: .error, category: .database) + return try makeInMemoryContainer() + } + } + + func destroy() throws { + let persistentStoreURL = storeURL ?? persistentContainer.persistentStoreDescriptions.first?.url + guard let persistentStoreURL else { + Logger.common(message: MBDatabaseError.persistentStoreURLNotFound.errorDescription, level: .error, category: .database) + throw MBDatabaseError.persistentStoreURLNotFound + } + + let psc = persistentContainer.persistentStoreCoordinator + Logger.common(message: "[DBLoader] Removing database at path: \(persistentStoreURL.path)", + level: .info, category: .database) + + do { + if #available(iOS 15.0, *) { + try psc.destroyPersistentStore(at: persistentStoreURL, type: .sqlite) + } else { + try psc.destroyPersistentStore(at: persistentStoreURL, ofType: NSSQLiteStoreType) + } + + Logger.common(message: "[DBLoader] Database removed at \(persistentStoreURL.path)", level: .info, category: .database) + } catch { + Logger.common(message: "[DBLoader] Failed to remove database: \(error.localizedDescription)", level: .error, category: .database) + throw error + } + } +} diff --git a/Mindbox/Database/DatabaseLoader/DatabaseLoaderProtocol.swift b/Mindbox/Database/DatabaseLoader/DatabaseLoaderProtocol.swift new file mode 100644 index 000000000..d8cd80cac --- /dev/null +++ b/Mindbox/Database/DatabaseLoader/DatabaseLoaderProtocol.swift @@ -0,0 +1,16 @@ +// +// DatabaseLoaderProtocol.swift +// Mindbox +// +// Created by Sergei Semko on 9/19/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import Foundation +import CoreData + +protocol DatabaseLoaderProtocol { + func loadPersistentContainer() throws -> NSPersistentContainer + func makeInMemoryContainer() throws -> NSPersistentContainer + func destroy() throws +} diff --git a/Mindbox/Database/MBDatabaseError.swift b/Mindbox/Database/DatabaseLoader/MBDatabaseError.swift similarity index 63% rename from Mindbox/Database/MBDatabaseError.swift rename to Mindbox/Database/DatabaseLoader/MBDatabaseError.swift index a646ae4f9..31e9e9906 100644 --- a/Mindbox/Database/MBDatabaseError.swift +++ b/Mindbox/Database/DatabaseLoader/MBDatabaseError.swift @@ -19,15 +19,15 @@ public enum MBDatabaseError: LocalizedError { public var errorDescription: String { switch self { case .unableCreateDatabaseModel: - return "[DataBaseLoader] Unable to create \(Constants.Database.mombName).xcdatamodel" + return "[DBLoader] Unable to create \(Constants.Database.mombName).xcdatamodel" case .unableCreateManagedObjectModel(let url): - return "[DataBaseLoader] Unable to create NSManagedObjectModel from url: \(url)" + return "[DBLoader] Unable to create NSManagedObjectModel from url: \(url)" case .unableToLoadPeristentStore(let localizedDescription): - return "[DataBaseLoader] Unable to load persistent store with error: \(localizedDescription)" + return "[DBLoader] Unable to load persistent store with error: \(localizedDescription)" case .persistentStoreURLNotFound: - return "[DataBaseLoader] Unable to find persistentStoreURL" + return "[DBLoader] Unable to find persistentStoreURL" case .persistentStoreNotExistsAtURL(let path): - return "[DataBaseLoader] Unable to find persistentStoreURL at path: \(path)" + return "[DBLoader] Unable to find persistentStoreURL at path: \(path)" } } } diff --git a/Mindbox/Database/DatabaseLoader/StubDatabaseLoader.swift b/Mindbox/Database/DatabaseLoader/StubDatabaseLoader.swift new file mode 100644 index 000000000..dd35738d1 --- /dev/null +++ b/Mindbox/Database/DatabaseLoader/StubDatabaseLoader.swift @@ -0,0 +1,16 @@ +// +// StubDatabaseLoader.swift +// Mindbox +// +// Created by Sergei Semko on 9/29/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import Foundation +import CoreData + +final class StubDatabaseLoader: DatabaseLoaderProtocol { + func loadPersistentContainer() throws -> NSPersistentContainer { throw MBDatabaseError.unableCreateDatabaseModel } + func makeInMemoryContainer() throws -> NSPersistentContainer { throw MBDatabaseError.unableCreateDatabaseModel } + func destroy() throws {} +} diff --git a/Mindbox/Database/DatabaseRepository/DatabaseRepositoryProtocol.swift b/Mindbox/Database/DatabaseRepository/DatabaseRepositoryProtocol.swift new file mode 100644 index 000000000..449903b29 --- /dev/null +++ b/Mindbox/Database/DatabaseRepository/DatabaseRepositoryProtocol.swift @@ -0,0 +1,43 @@ +// +// DatabaseRepositoryProtocol.swift +// Mindbox +// +// Created by Sergei Semko on 9/23/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import Foundation + +protocol DatabaseRepositoryProtocol: AnyObject { + // lifecycle / limits + var limit: Int { get } + var lifeLimitDate: Date? { get } + var deprecatedLimit: Int { get } + var onObjectsDidChange: (() -> Void)? { get set } + + // metadata + var infoUpdateVersion: Int? { get set } + var installVersion: Int? { get set } + var instanceId: String? { get set } + + // CRUD + func create(event: Event) throws + func readEvent(by transactionId: String) throws -> Event? + func update(event: Event) throws + func delete(event: Event) throws + + // queries/maintenance + func query(fetchLimit: Int, retryDeadline: TimeInterval) throws -> [Event] + func removeDeprecatedEventsIfNeeded() throws + func countDeprecatedEvents() throws -> Int + func erase() throws + + @discardableResult + func countEvents() throws -> Int +} + +extension DatabaseRepositoryProtocol { + func query(fetchLimit: Int) throws -> [Event] { + try query(fetchLimit: fetchLimit, retryDeadline: Constants.Database.retryDeadline) + } +} diff --git a/Mindbox/Database/MBDatabaseRepository.swift b/Mindbox/Database/DatabaseRepository/MBDatabaseRepository.swift similarity index 72% rename from Mindbox/Database/MBDatabaseRepository.swift rename to Mindbox/Database/DatabaseRepository/MBDatabaseRepository.swift index 5bc96bf8c..8b67c1c47 100644 --- a/Mindbox/Database/MBDatabaseRepository.swift +++ b/Mindbox/Database/DatabaseRepository/MBDatabaseRepository.swift @@ -7,20 +7,19 @@ // import Foundation +import UIKit.UIApplication import CoreData import MindboxLogger -class MBDatabaseRepository { +class MBDatabaseRepository: DatabaseRepositoryProtocol { enum MetadataKey: String { case install = "ApplicationInstalledVersion" case infoUpdate = "ApplicationInfoUpdatedVersion" case instanceId = "ApplicationInstanceId" } - - let persistentContainer: NSPersistentContainer - private let context: NSManagedObjectContext - private let store: NSPersistentStore + + // MARK: DatabaseRepository properties - lifecycle / limits var limit: Int { return 10000 @@ -36,6 +35,8 @@ class MBDatabaseRepository { } return monthLimitDate } + + // MARK: DatabaseRepository properties - metadata var infoUpdateVersion: Int? { get { getMetadata(forKey: .infoUpdate) } @@ -51,6 +52,19 @@ class MBDatabaseRepository { get { getMetadata(forKey: .instanceId) } set { setMetadata(newValue, forKey: .instanceId) } } + + // MARK: CoreData properties + + let persistentContainer: NSPersistentContainer + private let context: NSManagedObjectContext + private let store: NSPersistentStore + + // MARK: Private properties + + private var memoryWarningToken: NSObjectProtocol? + private var isPruningOnWarning = false + + // MARK: Initializer init(persistentContainer: NSPersistentContainer) throws { self.persistentContainer = persistentContainer @@ -63,23 +77,72 @@ class MBDatabaseRepository { self.context = persistentContainer.newBackgroundContext() self.context.automaticallyMergesChangesFromParent = true self.context.mergePolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) + + startMemoryWarningObserverIfNeeded() + _ = try countEvents() } - - // MARK: - CRUD operations - func create(event: Event) throws { - try context.executePerformAndWait { - let entity = CDEvent(context: context) - entity.transactionId = event.transactionId - entity.timestamp = Date().timeIntervalSince1970 - entity.type = event.type.rawValue - entity.body = event.body - Logger.common(message: "[MBDBRepo] Creating event `\(event.type.rawValue)` with transactionId: \(event.transactionId)", level: .info, category: .database) - try saveEvent(withContext: context) + + deinit { + if let token = memoryWarningToken { + NotificationCenter.default.removeObserver(token) + memoryWarningToken = nil } } + + // MARK: Private methods + + private func startMemoryWarningObserverIfNeeded() { + guard store.type == NSInMemoryStoreType, memoryWarningToken == nil else { return } + memoryWarningToken = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleMemoryWarning() + } + Logger.common(message: "[MBDBRepo] Memory warning observer is active (in-memory store).", level: .info, category: .database) + } + + private func handleMemoryWarning() { + guard store.type == NSInMemoryStoreType else { return } + if isPruningOnWarning { return } + isPruningOnWarning = true + + let bg = persistentContainer.newBackgroundContext() + bg.mergePolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) + + bg.perform { [weak self] in + guard let self = self else { return } + do { + let req: NSFetchRequest = CDEvent.fetchRequestForDelete(lifeLimitDate: nil) + req.includesPropertyValues = false + + let objects = try bg.fetch(req) + objects.forEach(bg.delete) + + if bg.hasChanges { try bg.save() } + bg.reset() + + DispatchQueue.main.async { self.onObjectsDidChange?() } - func read(by transactionId: String) throws -> CDEvent? { + Logger.common( + message: "[MBDBRepo] Aggressive prune on memory warning: removed \(objects.count) events (in-memory).", + level: .info, + category: .database + ) + } catch { + Logger.common( + message: "[MBDBRepo] Aggressive prune failed on memory warning: \(error)", + level: .error, + category: .database + ) + } + DispatchQueue.main.async { self.isPruningOnWarning = false } + } + } + + private func read(by transactionId: String) throws -> CDEvent? { try context.executePerformAndWait { Logger.common(message: "[MBDBRepo] Reading event with transactionId: \(transactionId)", level: .info, category: .database) let request: NSFetchRequest = CDEvent.fetchRequest(by: transactionId) @@ -91,6 +154,63 @@ class MBDatabaseRepository { return entity } } + + private func cleanUp(count: Int) { + let fetchLimit = count - limit + guard fetchLimit > .zero else { return } + + let request: NSFetchRequest = CDEvent.fetchRequestForDelete() + request.fetchLimit = fetchLimit + do { + try delete(by: request, withContext: context) + } catch { + Logger.common(message: "[MBDBRepo] Unable to remove elements", level: .error, category: .database) + } + } + + private func delete(by request: NSFetchRequest, withContext context: NSManagedObjectContext) throws { + try context.executePerformAndWait { + Logger.common(message: "[MBDBRepo] Finding elements to remove", level: .info, category: .database) + + let events = try context.fetch(request) + guard !events.isEmpty else { + Logger.common(message: "[MBDBRepo] Elements to remove not found", level: .info, category: .database) + return + } + events.forEach { + Logger.common(message: "[MBDBRepo] Remove element `\(String(describing: $0.type))` with transactionId: \(String(describing: $0.transactionId)) and timestamp: \(Date(timeIntervalSince1970: $0.timestamp))", + level: .info, category: .database) + context.delete($0) + } + try saveEvent(withContext: context) + } + } + + private func findEvent(by request: NSFetchRequest) throws -> CDEvent? { + try context.registeredObjects + .compactMap { $0 as? CDEvent } + .first(where: { !$0.isFault && request.predicate?.evaluate(with: $0) ?? false }) + ?? context.fetch(request).first + } + + // MARK: - DatabaseRepository: CRUD operations + + func create(event: Event) throws { + try context.executePerformAndWait { + let entity = CDEvent(context: context) + entity.transactionId = event.transactionId + entity.timestamp = Date().timeIntervalSince1970 + entity.type = event.type.rawValue + entity.body = event.body + Logger.common(message: "[MBDBRepo] Creating event `\(event.type.rawValue)` with transactionId: \(event.transactionId)", level: .info, category: .database) + try saveEvent(withContext: context) + } + } + + func readEvent(by transactionId: String) throws -> Event? { + guard let entity = try read(by: transactionId) else { return nil } + return Event(entity) + } func update(event: Event) throws { try context.executePerformAndWait { @@ -117,8 +237,10 @@ class MBDatabaseRepository { try saveEvent(withContext: context) } } + + // MARK: - DatabaseRepository: queries and maintenance - func query(fetchLimit: Int, retryDeadline: TimeInterval = 60) throws -> [Event] { + func query(fetchLimit: Int, retryDeadline: TimeInterval = Constants.Database.retryDeadline) throws -> [Event] { try context.executePerformAndWait { let request: NSFetchRequest = CDEvent.fetchRequestForSend(lifeLimitDate: lifeLimitDate, retryDeadLine: retryDeadline) request.fetchLimit = fetchLimit @@ -164,14 +286,42 @@ class MBDatabaseRepository { } func erase() throws { - let fetchRequest = NSFetchRequest(entityName: "CDEvent") - let eraseRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + infoUpdateVersion = nil - installVersion = nil + installVersion = nil + + let entityName = "CDEvent" + + if store.type == NSInMemoryStoreType { + let fetch = NSFetchRequest(entityName: entityName) + fetch.includesPropertyValues = false + + try context.executePerformAndWait { + let objects = try (context.fetch(fetch) as? [NSManagedObject]) ?? [] + objects.forEach(context.delete) + + if context.hasChanges { + try context.save() + } + context.reset() + } + return + } + + let fetch = NSFetchRequest(entityName: entityName) + let delete = NSBatchDeleteRequest(fetchRequest: fetch) + delete.resultType = .resultTypeObjectIDs + try context.executePerformAndWait { - try context.execute(eraseRequest) - try saveEvent(withContext: context) - try countEvents() + if let result = try context.execute(delete) as? NSBatchDeleteResult, + let ids = result.result as? [NSManagedObjectID], + !ids.isEmpty { + NSManagedObjectContext.mergeChanges( + fromRemoteContextSave: [NSDeletedObjectsKey: ids], + into: [context] + ) + } + context.reset() } } @@ -189,47 +339,10 @@ class MBDatabaseRepository { } } } - - private func cleanUp(count: Int) { - let fetchLimit = count - limit - guard fetchLimit > .zero else { return } - - let request: NSFetchRequest = CDEvent.fetchRequestForDelete() - request.fetchLimit = fetchLimit - do { - try delete(by: request, withContext: context) - } catch { - Logger.common(message: "[MBDBRepo] Unable to remove elements", level: .error, category: .database) - } - } - - private func delete(by request: NSFetchRequest, withContext context: NSManagedObjectContext) throws { - try context.executePerformAndWait { - Logger.common(message: "[MBDBRepo] Finding elements to remove", level: .info, category: .database) - - let events = try context.fetch(request) - guard !events.isEmpty else { - Logger.common(message: "[MBDBRepo] Elements to remove not found", level: .info, category: .database) - return - } - events.forEach { - Logger.common(message: "[MBDBRepo] Remove element `\(String(describing: $0.type))` with transactionId: \(String(describing: $0.transactionId)) and timestamp: \(Date(timeIntervalSince1970: $0.timestamp))", - level: .info, category: .database) - context.delete($0) - } - try saveEvent(withContext: context) - } - } - - private func findEvent(by request: NSFetchRequest) throws -> CDEvent? { - try context.registeredObjects - .compactMap { $0 as? CDEvent } - .first(where: { !$0.isFault && request.predicate?.evaluate(with: $0) ?? false }) - ?? context.fetch(request).first - } } // MARK: - ManagedObjectContext save processing + private extension MBDatabaseRepository { func saveEvent(withContext context: NSManagedObjectContext) throws { @@ -257,6 +370,7 @@ private extension MBDatabaseRepository { } // MARK: - Metadata processing + private extension MBDatabaseRepository { func getMetadata(forKey key: MetadataKey) -> T? { diff --git a/Mindbox/Database/DatabaseRepository/NoopDatabaseRepository.swift b/Mindbox/Database/DatabaseRepository/NoopDatabaseRepository.swift new file mode 100644 index 000000000..953b91c5f --- /dev/null +++ b/Mindbox/Database/DatabaseRepository/NoopDatabaseRepository.swift @@ -0,0 +1,41 @@ +// +// NoopDatabaseRepository.swift +// Mindbox +// +// Created by Sergei Semko on 9/29/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import Foundation + +final class NoopDatabaseRepository: DatabaseRepositoryProtocol { + + var limit: Int { 10_000 } + var lifeLimitDate: Date? + var deprecatedLimit: Int { 0 } + var onObjectsDidChange: (() -> Void)? + + var infoUpdateVersion: Int? { + get { nil } + set { /* no-op */ } // swiftlint:disable:this unused_setter_value + } + var installVersion: Int? { + get { nil } + set { /* no-op */ } // swiftlint:disable:this unused_setter_value + } + var instanceId: String? { + get { nil } + set { /* no-op */ } // swiftlint:disable:this unused_setter_value + } + + func create(event: Event) throws { /* no-op */ } + func readEvent(by transactionId: String) throws -> Event? { nil } + func update(event: Event) throws { /* no-op */ } + func delete(event: Event) throws { /* no-op */ } + + func query(fetchLimit: Int, retryDeadline: TimeInterval) throws -> [Event] { [] } + func removeDeprecatedEventsIfNeeded() throws { /* no-op */ } + func countDeprecatedEvents() throws -> Int { 0 } + func erase() throws { /* no-op */ } + func countEvents() throws -> Int { 0 } +} diff --git a/Mindbox/GuaranteedDeliveryManager/Background/BGTaskManager.swift b/Mindbox/GuaranteedDeliveryManager/Background/BGTaskManager.swift index a03e2db27..8ebb6b807 100644 --- a/Mindbox/GuaranteedDeliveryManager/Background/BGTaskManager.swift +++ b/Mindbox/GuaranteedDeliveryManager/Background/BGTaskManager.swift @@ -24,7 +24,7 @@ final class BGTaskManager: BackgroundTaskManagerType { private var appGDProcessingTask: BGProcessingTask? private let persistenceStorage: PersistenceStorage - private let databaseRepository: MBDatabaseRepository + private let databaseRepository: DatabaseRepositoryProtocol // MARK: BGTasks synchronizer @@ -48,7 +48,7 @@ final class BGTaskManager: BackgroundTaskManagerType { // MARK: Initializer - init(persistenceStorage: PersistenceStorage, databaseRepository: MBDatabaseRepository) { + init(persistenceStorage: PersistenceStorage, databaseRepository: DatabaseRepositoryProtocol) { self.persistenceStorage = persistenceStorage self.databaseRepository = databaseRepository } diff --git a/Mindbox/GuaranteedDeliveryManager/Background/BackgroundTaskManagerProxy.swift b/Mindbox/GuaranteedDeliveryManager/Background/BackgroundTaskManagerProxy.swift index 2ef2e7c4e..1ffc98648 100644 --- a/Mindbox/GuaranteedDeliveryManager/Background/BackgroundTaskManagerProxy.swift +++ b/Mindbox/GuaranteedDeliveryManager/Background/BackgroundTaskManagerProxy.swift @@ -23,9 +23,9 @@ class BackgroundTaskManagerProxy { private var taskManagers: [BackgroundTaskManagerType] = [] private let persistenceStorage: PersistenceStorage - private let databaseRepository: MBDatabaseRepository + private let databaseRepository: DatabaseRepositoryProtocol - init(persistenceStorage: PersistenceStorage, databaseRepository: MBDatabaseRepository) { + init(persistenceStorage: PersistenceStorage, databaseRepository: DatabaseRepositoryProtocol) { self.persistenceStorage = persistenceStorage self.databaseRepository = databaseRepository NotificationCenter.default.addObserver( diff --git a/Mindbox/GuaranteedDeliveryManager/Background/UIBackgroundTaskManager.swift b/Mindbox/GuaranteedDeliveryManager/Background/UIBackgroundTaskManager.swift index 6f9e00125..b088882a2 100644 --- a/Mindbox/GuaranteedDeliveryManager/Background/UIBackgroundTaskManager.swift +++ b/Mindbox/GuaranteedDeliveryManager/Background/UIBackgroundTaskManager.swift @@ -15,9 +15,9 @@ class UIBackgroundTaskManager: BackgroundTaskManagerType { weak var gdManager: GuaranteedDeliveryManager? private let persistenceStorage: PersistenceStorage - private let databaseRepository: MBDatabaseRepository + private let databaseRepository: DatabaseRepositoryProtocol - init(persistenceStorage: PersistenceStorage, databaseRepository: MBDatabaseRepository) { + init(persistenceStorage: PersistenceStorage, databaseRepository: DatabaseRepositoryProtocol) { self.persistenceStorage = persistenceStorage self.databaseRepository = databaseRepository } diff --git a/Mindbox/GuaranteedDeliveryManager/DeliveryOperation.swift b/Mindbox/GuaranteedDeliveryManager/DeliveryOperation.swift index b5827b727..ff3f8a8aa 100644 --- a/Mindbox/GuaranteedDeliveryManager/DeliveryOperation.swift +++ b/Mindbox/GuaranteedDeliveryManager/DeliveryOperation.swift @@ -12,10 +12,10 @@ import MindboxLogger class DeliveryOperation: AsyncOperation, @unchecked Sendable { private let event: Event - private let databaseRepository: MBDatabaseRepository + private let databaseRepository: DatabaseRepositoryProtocol private let eventRepository: EventRepository - init(databaseRepository: MBDatabaseRepository, eventRepository: EventRepository, event: Event) { + init(databaseRepository: DatabaseRepositoryProtocol, eventRepository: EventRepository, event: Event) { self.databaseRepository = databaseRepository self.eventRepository = eventRepository self.event = event diff --git a/Mindbox/GuaranteedDeliveryManager/GuaranteedDeliveryManager.swift b/Mindbox/GuaranteedDeliveryManager/GuaranteedDeliveryManager.swift index 225b4fa26..53898f3f7 100644 --- a/Mindbox/GuaranteedDeliveryManager/GuaranteedDeliveryManager.swift +++ b/Mindbox/GuaranteedDeliveryManager/GuaranteedDeliveryManager.swift @@ -14,7 +14,7 @@ import MindboxLogger final class GuaranteedDeliveryManager: NSObject { - private let databaseRepository: MBDatabaseRepository + private let databaseRepository: DatabaseRepositoryProtocol private let eventRepository: EventRepository let backgroundTaskManager: BackgroundTaskManagerProxy @@ -51,7 +51,7 @@ final class GuaranteedDeliveryManager: NSObject { init( persistenceStorage: PersistenceStorage, - databaseRepository: MBDatabaseRepository, + databaseRepository: DatabaseRepositoryProtocol, eventRepository: EventRepository, retryDeadline: TimeInterval = 60, fetchLimit: Int = 20 diff --git a/Mindbox/InAppMessages/InAppMessagesTracker.swift b/Mindbox/InAppMessages/InAppMessagesTracker.swift index 767f73329..73e478394 100644 --- a/Mindbox/InAppMessages/InAppMessagesTracker.swift +++ b/Mindbox/InAppMessages/InAppMessagesTracker.swift @@ -23,9 +23,9 @@ class InAppMessagesTracker: InAppMessagesTrackerProtocol, InappTargetingTrackPro let inappId: String } - private let databaseRepository: MBDatabaseRepository + private let databaseRepository: DatabaseRepositoryProtocol - init(databaseRepository: MBDatabaseRepository) { + init(databaseRepository: DatabaseRepositoryProtocol) { self.databaseRepository = databaseRepository } diff --git a/Mindbox/Mindbox.swift b/Mindbox/Mindbox.swift index 0070e82b1..255868c7c 100644 --- a/Mindbox/Mindbox.swift +++ b/Mindbox/Mindbox.swift @@ -38,7 +38,7 @@ public class Mindbox: NSObject { private var persistenceStorage: PersistenceStorage? private var utilitiesFetcher: UtilitiesFetcher? private var guaranteedDeliveryManager: GuaranteedDeliveryManager? - private var databaseRepository: MBDatabaseRepository? + private var databaseRepository: DatabaseRepositoryProtocol? private var inappScheduleManager: InappScheduleManagerProtocol? private var sessionTemporaryStorage: SessionTemporaryStorage? private var trackVisitManager: TrackVisitCommonTrackProtocol? @@ -553,7 +553,7 @@ public class Mindbox: NSObject { persistenceStorage = DI.injectOrFail(PersistenceStorage.self) utilitiesFetcher = DI.injectOrFail(UtilitiesFetcher.self) guaranteedDeliveryManager = DI.injectOrFail(GuaranteedDeliveryManager.self) - databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) inappScheduleManager = DI.injectOrFail(InappScheduleManagerProtocol.self) inAppMessagesDelegate = self coreController = DI.injectOrFail(CoreController.self) diff --git a/Mindbox/Model/Event.swift b/Mindbox/Model/Event.swift index 4da4d6de7..e65ce094a 100644 --- a/Mindbox/Model/Event.swift +++ b/Mindbox/Model/Event.swift @@ -9,19 +9,6 @@ import Foundation import MindboxLogger -protocol EventProtocol { - var transactionId: String { get } - var dateTimeOffset: Int64 { get } - var enqueueTimeStamp: Double { get } - var serialNumber: String? { get } - var type: Event.Operation { get } - var isRetry: Bool { get } - var body: String { get } - - init(type: Event.Operation, body: String) - init?(_ event: CDEvent) -} - struct Event: EventProtocol { enum Operation: String { @@ -59,7 +46,10 @@ struct Event: EventProtocol { let type: Operation // True if first attempt to send was failed - let isRetry: Bool + var isRetry: Bool { !retryTimestamp.isZero } + + let retryTimestamp: Double + // Data according to Operation let body: String @@ -69,7 +59,7 @@ struct Event: EventProtocol { self.type = type self.body = body self.serialNumber = nil - self.isRetry = false + self.retryTimestamp = 0 } init?(_ event: CDEvent) { @@ -90,6 +80,22 @@ struct Event: EventProtocol { self.type = operation self.body = body self.serialNumber = event.objectID.uriRepresentation().lastPathComponent - self.isRetry = !event.retryTimestamp.isZero + self.retryTimestamp = event.retryTimestamp } } + +// MARK: For test purposes + +protocol EventProtocol { + var transactionId: String { get } + var dateTimeOffset: Int64 { get } + var enqueueTimeStamp: Double { get } + var serialNumber: String? { get } + var type: Event.Operation { get } + var isRetry: Bool { get } + var retryTimestamp: Double { get } + var body: String { get } + + init(type: Event.Operation, body: String) + init?(_ event: CDEvent) +} diff --git a/Mindbox/TrackVisitManager/TrackVisitManager.swift b/Mindbox/TrackVisitManager/TrackVisitManager.swift index 61870ed29..c7804fc1e 100644 --- a/Mindbox/TrackVisitManager/TrackVisitManager.swift +++ b/Mindbox/TrackVisitManager/TrackVisitManager.swift @@ -22,11 +22,11 @@ protocol TrackVisitSpecificTrackProtocol { protocol TrackVisitManagerProtocol: TrackVisitCommonTrackProtocol, TrackVisitSpecificTrackProtocol {} final class TrackVisitManager: TrackVisitManagerProtocol { - private let databaseRepository: MBDatabaseRepository + private let databaseRepository: DatabaseRepositoryProtocol private let inappSessionManager: InappSessionManagerProtocol init( - databaseRepository: MBDatabaseRepository, + databaseRepository: DatabaseRepositoryProtocol, inappSessionManager: InappSessionManagerProtocol ) { self.databaseRepository = databaseRepository diff --git a/Mindbox/Utilities/Constants.swift b/Mindbox/Utilities/Constants.swift index 6d716cb8d..027094e81 100644 --- a/Mindbox/Utilities/Constants.swift +++ b/Mindbox/Utilities/Constants.swift @@ -21,6 +21,7 @@ enum Constants { enum Database { static let mombName = "MBDatabase" + static let retryDeadline: TimeInterval = 60 } enum Notification { diff --git a/MindboxTests/DI/DIMainModuleRegistrationTests.swift b/MindboxTests/DI/DIMainModuleRegistrationTests.swift index 5de9e3208..19e6be961 100644 --- a/MindboxTests/DI/DIMainModuleRegistrationTests.swift +++ b/MindboxTests/DI/DIMainModuleRegistrationTests.swift @@ -62,7 +62,7 @@ final class DIMainModuleRegistrationTests: XCTestCase { } func testDatabaseRepositoryIsRegistered() { - let repository: MBDatabaseRepository? = DI.inject(MBDatabaseRepository.self) + let repository: DatabaseRepositoryProtocol? = DI.inject(DatabaseRepositoryProtocol.self) XCTAssertNotNil(repository) } @@ -118,7 +118,7 @@ final class DIMainModuleRegistrationTests: XCTestCase { } func testDataBaseLoaderIsRegistered() { - let loader: DataBaseLoader? = DI.inject(DataBaseLoader.self) + let loader: DatabaseLoaderProtocol? = DI.inject(DatabaseLoaderProtocol.self) XCTAssertNotNil(loader) } diff --git a/MindboxTests/DI/DIMainModuleReplaceableTests.swift b/MindboxTests/DI/DIMainModuleReplaceableTests.swift index 23b7a20cd..af40e33d4 100644 --- a/MindboxTests/DI/DIMainModuleReplaceableTests.swift +++ b/MindboxTests/DI/DIMainModuleReplaceableTests.swift @@ -39,7 +39,7 @@ class DIMainModuleReplaceableTests: XCTestCase { } func testDatabaseRepositoryIsRegistered() { - let repository: MBDatabaseRepository? = DI.inject(MBDatabaseRepository.self) + let repository: DatabaseRepositoryProtocol? = DI.inject(DatabaseRepositoryProtocol.self) XCTAssertNotNil(repository) } diff --git a/MindboxTests/DI/DITestModuleReplaceableTests.swift b/MindboxTests/DI/DITestModuleReplaceableTests.swift index 166b9da43..1ed539075 100644 --- a/MindboxTests/DI/DITestModuleReplaceableTests.swift +++ b/MindboxTests/DI/DITestModuleReplaceableTests.swift @@ -39,7 +39,7 @@ class DITestModuleReplaceableTests: XCTestCase { } func testDatabaseRepositoryIsRegistered() { - let repository: MBDatabaseRepository? = DI.inject(MBDatabaseRepository.self) + let repository: DatabaseRepositoryProtocol? = DI.inject(DatabaseRepositoryProtocol.self) XCTAssertNotNil(repository) XCTAssert(repository is MockDatabaseRepository) } diff --git a/MindboxTests/DI/DITests.swift b/MindboxTests/DI/DITests.swift index 099747bf8..29a46a89e 100644 --- a/MindboxTests/DI/DITests.swift +++ b/MindboxTests/DI/DITests.swift @@ -39,7 +39,7 @@ class TestModeRegistrationTests: XCTestCase { } func testDatabaseRepositoryIsRegistered() { - let repository: MBDatabaseRepository? = DI.inject(MBDatabaseRepository.self) + let repository: DatabaseRepositoryProtocol? = DI.inject(DatabaseRepositoryProtocol.self) XCTAssertNotNil(repository) XCTAssert(repository is MockDatabaseRepository) } diff --git a/MindboxTests/DI/Injections/InjectionMocks.swift b/MindboxTests/DI/Injections/InjectionMocks.swift index ddc91e83b..f6e8da600 100644 --- a/MindboxTests/DI/Injections/InjectionMocks.swift +++ b/MindboxTests/DI/Injections/InjectionMocks.swift @@ -29,7 +29,7 @@ extension MBContainer { MockPersistenceStorage() } - register(MBDatabaseRepository.self) { + register(DatabaseRepositoryProtocol.self) { return try! MockDatabaseRepository(inMemory: true) } diff --git a/MindboxTests/Database/DataBaseLoading_StubDatabaseLoaderContractTests.swift b/MindboxTests/Database/DataBaseLoading_StubDatabaseLoaderContractTests.swift new file mode 100644 index 000000000..9d104ceca --- /dev/null +++ b/MindboxTests/Database/DataBaseLoading_StubDatabaseLoaderContractTests.swift @@ -0,0 +1,61 @@ +// +// DataBaseLoading_StubDatabaseLoaderContractTests.swift +// MindboxTests +// +// Created by Sergei Semko on 9/29/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import XCTest +@testable import Mindbox + +final class DataBaseLoading_StubDatabaseLoaderContractTests: XCTestCase { + + private var loader: DatabaseLoaderProtocol! + + override func setUp() { + super.setUp() + loader = StubDatabaseLoader() + } + + override func tearDown() { + loader = nil + super.tearDown() + } + + func test_loadPersistentContainer_throwsSpecificError() { + XCTAssertThrowsError(try loader.loadPersistentContainer(), "StubLoader must throw on loadPersistentContainer()") { error in + if let e = error as? MBDatabaseError { + switch e { + case .unableCreateDatabaseModel: + // ok + break + default: + XCTFail("Expected .unableCreateDatabaseModel, got \(e)") + } + } else { + XCTFail("Expected MBDatabaseError, got \(type(of: error)): \(error)") + } + } + } + + func test_makeInMemoryContainer_throwsSpecificError() { + XCTAssertThrowsError(try loader.makeInMemoryContainer(), "StubLoader must throw on makeInMemoryContainer()") { error in + if let e = error as? MBDatabaseError { + switch e { + case .unableCreateDatabaseModel: + // ok + break + default: + XCTFail("Expected .unableCreateDatabaseModel, got \(e)") + } + } else { + XCTFail("Expected MBDatabaseError, got \(type(of: error)): \(error)") + } + } + } + + func test_destroy_doesNotThrow() { + XCTAssertNoThrow(try loader.destroy(), "StubLoader destroy() must not throw") + } +} diff --git a/MindboxTests/Database/DatabaseLoaderFlowTests.swift b/MindboxTests/Database/DatabaseLoaderFlowTests.swift new file mode 100644 index 000000000..87828de4e --- /dev/null +++ b/MindboxTests/Database/DatabaseLoaderFlowTests.swift @@ -0,0 +1,190 @@ +// +// DatabaseLoaderFlowTests.swift +// MindboxTests +// +// Created by Sergei Semko on 9/25/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import XCTest +import CoreData +@testable import Mindbox + +final class DatabaseLoaderFlowTests: XCTestCase { + + private var tempDir: URL! + private let fm = FileManager.default + + override func setUp() { + super.setUp() + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + tempDir = base.appendingPathComponent("MindboxDBFlow-\(UUID().uuidString)", isDirectory: true) + try? fm.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDown() { + if let tempDir { try? fm.removeItem(at: tempDir) } + tempDir = nil + super.tearDown() + } + + // Factory to create a loader pointing to a real SQLite URL + private func makeDescAndURL(dbName: String) -> (NSPersistentStoreDescription, URL) { + let url = tempDir.appendingPathComponent("\(dbName).sqlite") + let desc = NSPersistentStoreDescription(url: url) + desc.type = NSSQLiteStoreType + return (desc, url) + } + + // Spy/Stub loader + final class SpyDatabaseLoader: DatabaseLoader { + enum LoadMode { case succeed, failAlways, failThenSucceed } + + var stubFreeSize: Int64 = .max + override var freeSize: Int64 { stubFreeSize } + + var loadMode: LoadMode = .succeed + private var loadAttempts = 0 + + var destroyCallCount = 0 + var shouldDestroyThrow = false + + var salvageCalled = false + var stubPreserved: [String: Any]? = nil + + var applyCaptured: [String: Any]? = nil + + override func loadPersistentStores() throws -> NSPersistentContainer { + loadAttempts += 1 + switch loadMode { + case .succeed: + return try super.loadPersistentStores() + case .failAlways: + throw NSError(domain: NSCocoaErrorDomain, code: 256) + case .failThenSucceed: + if loadAttempts == 1 { + throw NSError(domain: NSCocoaErrorDomain, code: 256) + } else { + return try super.loadPersistentStores() + } + } + } + + override func destroy() throws { + destroyCallCount += 1 + if shouldDestroyThrow { throw NSError(domain: "test", code: 1) } + try super.destroy() + } + + override func salvageMetadataFromOnDiskStore() -> [String : Any]? { + salvageCalled = true + return stubPreserved + } + + override func applyMetadata(_ preserved: [String : Any], to container: NSPersistentContainer) { + applyCaptured = preserved + super.applyMetadata(preserved, to: container) + } + } + + private func makeSpyLoader(dbName: String = "Flow") throws -> (SpyDatabaseLoader, URL) { + let (desc, url) = makeDescAndURL(dbName: dbName) + let loader = try SpyDatabaseLoader(persistentStoreDescriptions: [desc], applicationGroupIdentifier: nil) + return (loader, url) + } + + // Helpers to read current store metadata + private func currentStoreMetadata(on container: NSPersistentContainer) throws -> [String: Any] { + let psc = container.persistentStoreCoordinator + let store = try XCTUnwrap(psc.persistentStores.first, "Expected a persistent store") + return psc.metadata(for: store) + } + + // 1) Straight success: no salvage, no destroy + func test_Flow_StraightSuccess_SkipsRepair() throws { + let (loader, _) = try makeSpyLoader(dbName: "Straight") + loader.loadMode = .succeed + loader.stubPreserved = [ + MBDatabaseRepository.MetadataKey.instanceId.rawValue: "should-not-be-used" + ] + + let container = try loader.loadPersistentContainer() + let store = try XCTUnwrap(container.persistentStoreCoordinator.persistentStores.first) + XCTAssertEqual(store.type, NSSQLiteStoreType, "Store must be on-disk SQLite on straight success") + XCTAssertEqual(loader.destroyCallCount, 0, "destroy must not be called on straight success") + XCTAssertFalse(loader.salvageCalled, "salvage must not be called on straight success") + XCTAssertNil(loader.applyCaptured, "applyMetadata must not be called on straight success") + } + + // 2) Load fails + low disk → in-memory + metadata applied + func test_Flow_LowDiskSpace_UsesInMemory_AndAppliesMetadata() throws { + let (loader, _) = try makeSpyLoader(dbName: "LowDisk") + loader.loadMode = .failAlways + loader.stubFreeSize = 1 // definitely below threshold + let preserved: [String: Any] = [ + MBDatabaseRepository.MetadataKey.install.rawValue: 42, + MBDatabaseRepository.MetadataKey.infoUpdate.rawValue: 7, + MBDatabaseRepository.MetadataKey.instanceId.rawValue: "im-low-disk" + ] + loader.stubPreserved = preserved + + let container = try loader.loadPersistentContainer() + let psc = container.persistentStoreCoordinator + let store = try XCTUnwrap(psc.persistentStores.first) + XCTAssertEqual(store.type, NSInMemoryStoreType, "Should fall back to in-memory when disk is low") + XCTAssertEqual(loader.destroyCallCount, 0, "destroy must not be called on low-disk fallback") + XCTAssertTrue(loader.salvageCalled, "salvage must be called before fallback") + XCTAssertEqual(loader.applyCaptured?.count, preserved.count, "All preserved keys must be applied") + + let meta = try currentStoreMetadata(on: container) + for (k, v) in preserved { + XCTAssertEqual(meta[k] as? NSObject, v as? NSObject, "Preserved metadata '\(k)' must be applied") + } + } + + // 3) Load fails + enough disk → destroy+retry succeeds, metadata applied to new on-disk store + func test_Flow_DestroyAndRetry_Succeeds_AppliesMetadata() throws { + let (loader, _) = try makeSpyLoader(dbName: "RepairSuccess") + loader.loadMode = .failThenSucceed + loader.stubFreeSize = .max // not low + let preserved: [String: Any] = [ + MBDatabaseRepository.MetadataKey.install.rawValue: 9, + MBDatabaseRepository.MetadataKey.infoUpdate.rawValue: 3, + MBDatabaseRepository.MetadataKey.instanceId.rawValue: "repaired-ok" + ] + loader.stubPreserved = preserved + + let container = try loader.loadPersistentContainer() + let psc = container.persistentStoreCoordinator + let store = try XCTUnwrap(psc.persistentStores.first) + XCTAssertEqual(store.type, NSSQLiteStoreType, "After destroy+retry the store must be on-disk SQLite") + XCTAssertEqual(loader.destroyCallCount, 1, "destroy must be called exactly once on repair path") + XCTAssertTrue(loader.salvageCalled, "salvage must be called before repair") + XCTAssertEqual(loader.applyCaptured?.count, preserved.count, "All preserved keys must be applied after repair") + + let meta = try currentStoreMetadata(on: container) + for (k, v) in preserved { + XCTAssertEqual(meta[k] as? NSObject, v as? NSObject, "Repaired store must contain preserved metadata '\(k)'") + } + } + + // 4) Repair attempt fails → in-memory fallback + func test_Flow_DestroyAndRetry_Fails_FallsBackToInMemory() throws { + let (loader, _) = try makeSpyLoader(dbName: "RepairFails") + loader.loadMode = .failAlways + loader.stubFreeSize = .max // not low → go to repair path + loader.stubPreserved = [ + MBDatabaseRepository.MetadataKey.instanceId.rawValue: "will-fallback" + ] + // Option B: simulate destroy throwing + // loader.shouldDestroyThrow = true + + let container = try loader.loadPersistentContainer() + let psc = container.persistentStoreCoordinator + let store = try XCTUnwrap(psc.persistentStores.first) + XCTAssertEqual(store.type, NSInMemoryStoreType, "Should fall back to in-memory when repair fails") + XCTAssertGreaterThanOrEqual(loader.destroyCallCount, 1, "destroy should be attempted on repair path") + XCTAssertTrue(loader.salvageCalled, "salvage must be called on repair path") + } +} + diff --git a/MindboxTests/Database/DatabaseLoaderTest.swift b/MindboxTests/Database/DatabaseLoaderTest.swift index c977b7f7f..46fb586ee 100644 --- a/MindboxTests/Database/DatabaseLoaderTest.swift +++ b/MindboxTests/Database/DatabaseLoaderTest.swift @@ -3,37 +3,160 @@ // MindboxTests // // Created by Maksim Kazachkov on 07.04.2021. -// Copyright © 2021 Mindbox. All rights reserved. +// Copyright © 2025 Mindbox. All rights reserved. // import XCTest import CoreData @testable import Mindbox -// swiftlint:disable force_try force_unwrapping +final class DataBaseLoaderTests: XCTestCase { -class DatabaseLoaderTest: XCTestCase { + private enum Constants { + static let testsFolderPrefix = "MindboxDBTests-" + static let sqliteExtension = "sqlite" + static let defaultDatabaseName = "TestDB" + static let precreatedDatabaseName = "Precreated" + static let corruptedDatabaseName = "Corrupted" + static let inMemoryDatabaseName = "InMemoryOnly" - var persistentContainer: NSPersistentContainer! - var databaseLoader: DataBaseLoader! + static let nonSQLitePayload = "not a sqlite database" + static let garbagePayload = "garbage" + + static let devNullURLString = URL(fileURLWithPath: "/dev/null").absoluteString + } + + private var temporaryDirectoryURL: URL! + private let fileManager: FileManager = .default override func setUp() { super.setUp() - MBInject.mode = .standard - databaseLoader = DI.injectOrFail(DataBaseLoader.self) - persistentContainer = DI.injectOrFail(MBDatabaseRepository.self).persistentContainer + let baseTemporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + temporaryDirectoryURL = baseTemporaryDirectoryURL.appendingPathComponent( + Constants.testsFolderPrefix + UUID().uuidString, + isDirectory: true + ) + try? fileManager.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true) } override func tearDown() { - databaseLoader = nil - persistentContainer = nil + if let temporaryDirectoryURL { + try? fileManager.removeItem(at: temporaryDirectoryURL) + } + temporaryDirectoryURL = nil super.tearDown() } - func testDestroyDatabase() { - let persistentStoreURL = databaseLoader.persistentStoreURL! - XCTAssertNotNil(persistentContainer.persistentStoreCoordinator.persistentStore(for: persistentStoreURL)) - try! databaseLoader.destroy() - XCTAssertNil(persistentContainer.persistentStoreCoordinator.persistentStore(for: persistentStoreURL)) + // MARK: - Helpers + + private func makeDatabaseLoader(databaseName: String = Constants.defaultDatabaseName) + throws -> (loader: DatabaseLoader, storeURL: URL) { + let storeURL = temporaryDirectoryURL.appendingPathComponent("\(databaseName).\(Constants.sqliteExtension)") + let persistentStoreDescription = NSPersistentStoreDescription(url: storeURL) + persistentStoreDescription.type = NSSQLiteStoreType + + let databaseLoader = try DatabaseLoader( + persistentStoreDescriptions: [persistentStoreDescription], + applicationGroupIdentifier: nil + ) + return (databaseLoader, storeURL) + } + + // MARK: - Flow tests + + func test_LoadsOnDiskStore_Succeeds() throws { + let (databaseLoader, storeURL) = try makeDatabaseLoader() + let persistentContainer = try databaseLoader.loadPersistentContainer() + + let persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator + let persistentStore = try XCTUnwrap( + persistentStoreCoordinator.persistentStores.first, + "Persistent store should be loaded" + ) + XCTAssertEqual(persistentStore.type, NSSQLiteStoreType, "Store type must be SQLite") + XCTAssertTrue(fileManager.fileExists(atPath: storeURL.path), "SQLite file must exist on disk") + } + + func test_Destroy_DetachesStore_AndRecreateChangesStoreUUID() throws { + let (databaseLoader, storeURL) = try makeDatabaseLoader() + let persistentContainer = try databaseLoader.loadPersistentContainer() + let persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator + let persistentStore = try XCTUnwrap( + persistentStoreCoordinator.persistentStores.first, + "Store should be present before destroy" + ) + + // UUID before destroy + let storeUUIDBefore = persistentStoreCoordinator.metadata(for: persistentStore)[NSStoreUUIDKey] as? String + + // Destroy + try databaseLoader.destroy() + + // Store is detached from coordinator + XCTAssertNil( + persistentStoreCoordinator.persistentStore(for: storeURL), + "Store should be detached after destroy" + ) + + // Recreate + let recreatedContainer = try databaseLoader.loadPersistentContainer() + let recreatedCoordinator = recreatedContainer.persistentStoreCoordinator + let recreatedStore = try XCTUnwrap( + recreatedCoordinator.persistentStores.first, + "Store should be recreated after destroy" + ) + let storeUUIDAfter = recreatedCoordinator.metadata(for: recreatedStore)[NSStoreUUIDKey] as? String + XCTAssertNotEqual(storeUUIDBefore, storeUUIDAfter, "Store UUID should change after destroy+recreate") + } + + func test_Destroy_UsesDescriptionURL_WhenStoreNotLoaded() throws { + // Pre-create a garbage file at the expected URL without loading the store + let (databaseLoader, storeURL) = try makeDatabaseLoader(databaseName: Constants.precreatedDatabaseName) + try XCTUnwrap(Constants.garbagePayload.data(using: .utf8)).write(to: storeURL) + XCTAssertTrue(fileManager.fileExists(atPath: storeURL.path), "Precreated file must exist") + + // Destroy should clear path so we can load a fresh store afterwards + try databaseLoader.destroy() + + // The physical file may or may not remain; critical point is that a store loads fine + let persistentContainer = try databaseLoader.loadPersistentContainer() + let persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator + let persistentStore = try XCTUnwrap( + persistentStoreCoordinator.persistentStores.first, + "Store should load after destroy using description URL" + ) + XCTAssertEqual(persistentStore.type, NSSQLiteStoreType, "Store type must be SQLite") + } + + func test_LoadPersistentContainer_RetryAfterDestroy_OnCorruptedFile() throws { + let (databaseLoader, storeURL) = try makeDatabaseLoader(databaseName: Constants.corruptedDatabaseName) + try XCTUnwrap(Constants.nonSQLitePayload.data(using: .utf8)).write(to: storeURL) + + let persistentContainer = try databaseLoader.loadPersistentContainer() + let persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator + let persistentStore = persistentStoreCoordinator.persistentStores.first + + XCTAssertNotNil(persistentStore, "Store should be available after repair retry") + XCTAssertEqual(persistentStore?.type, NSSQLiteStoreType, "Store type must be SQLite after repair") + XCTAssertTrue(fileManager.fileExists(atPath: storeURL.path), "SQLite file must exist after repair") + } + + func test_MakeInMemoryContainer_ReturnsInMemoryStore() throws { + let (databaseLoader, _) = try makeDatabaseLoader(databaseName: Constants.inMemoryDatabaseName) + let inMemoryContainer = try databaseLoader.makeInMemoryContainer() + + let persistentStoreCoordinator = inMemoryContainer.persistentStoreCoordinator + let inMemoryStore = try XCTUnwrap( + persistentStoreCoordinator.persistentStores.first, + "In-memory store should be present" + ) + XCTAssertEqual(inMemoryStore.type, NSInMemoryStoreType, "Store type must be in-memory") + + // On iOS URL may be nil or /dev/null for in-memory store + let inMemoryURLString = inMemoryStore.url?.absoluteString + let isNilOrDevNull = (inMemoryURLString == nil || + inMemoryURLString == Constants.devNullURLString) + XCTAssertTrue(isNilOrDevNull, + "In-memory URL should be nil or /dev/null, got: \(inMemoryURLString ?? "nil")") } } diff --git a/MindboxTests/Database/DatabaseRepositoryTestCase.swift b/MindboxTests/Database/DatabaseRepositoryTestCase.swift index f3fa6bbb9..d20ef61a5 100644 --- a/MindboxTests/Database/DatabaseRepositoryTestCase.swift +++ b/MindboxTests/Database/DatabaseRepositoryTestCase.swift @@ -12,12 +12,12 @@ import CoreData class DatabaseRepositoryTestCase: XCTestCase { - var databaseRepository: MBDatabaseRepository! + var databaseRepository: DatabaseRepositoryProtocol! var eventGenerator: EventGenerator! override func setUpWithError() throws { super.setUp() - databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) eventGenerator = EventGenerator() try databaseRepository.erase() @@ -47,7 +47,7 @@ class DatabaseRepositoryTestCase: XCTestCase { let event = eventGenerator.generateEvent() try databaseRepository.create(event: event) - let entity = try databaseRepository.read(by: event.transactionId) + let entity = try databaseRepository.readEvent(by: event.transactionId) XCTAssertNotNil(entity) } @@ -57,13 +57,13 @@ class DatabaseRepositoryTestCase: XCTestCase { var updatedRetryTimeStamp: Double? try databaseRepository.create(event: event) - var entity = try databaseRepository.read(by: event.transactionId) + var entity = try databaseRepository.readEvent(by: event.transactionId) initialRetryTimeStamp = entity?.retryTimestamp XCTAssertNotNil(initialRetryTimeStamp) try databaseRepository.update(event: event) - entity = try databaseRepository.read(by: event.transactionId) + entity = try databaseRepository.readEvent(by: event.transactionId) XCTAssertNotNil(initialRetryTimeStamp) updatedRetryTimeStamp = entity?.retryTimestamp @@ -185,7 +185,7 @@ class DatabaseRepositoryTestCase: XCTestCase { } try events.forEach { - let fetchedEvent = try databaseRepository.read(by: $0.transactionId) + let fetchedEvent = try databaseRepository.readEvent(by: $0.transactionId) XCTAssertEqual(fetchedEvent?.retryTimestamp, 0.0) } @@ -194,7 +194,7 @@ class DatabaseRepositoryTestCase: XCTestCase { } try eventsToRetry.forEach { - let fetchedEvent = try databaseRepository.read(by: $0.transactionId) + let fetchedEvent = try databaseRepository.readEvent(by: $0.transactionId) let retryTimestamp = try XCTUnwrap(fetchedEvent?.retryTimestamp) XCTAssertGreaterThan(retryTimestamp, 0.0) } diff --git a/MindboxTests/Database/DatabaseRepository_NoopContractTests.swift b/MindboxTests/Database/DatabaseRepository_NoopContractTests.swift new file mode 100644 index 000000000..a6b9e17fc --- /dev/null +++ b/MindboxTests/Database/DatabaseRepository_NoopContractTests.swift @@ -0,0 +1,93 @@ +// +// DatabaseRepository_NoopContractTests.swift +// MindboxTests +// +// Created by Sergei Semko on 9/29/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import XCTest +@testable import Mindbox + +final class DatabaseRepository_NoopContractTests: XCTestCase { + + var repo: DatabaseRepositoryProtocol! + + override func setUp() { + super.setUp() + repo = NoopDatabaseRepository() + } + + override func tearDown() { + repo = nil + super.tearDown() + } + + func test_NoThrow_AllMutating() { + XCTAssertNoThrow(try repo.erase()) + XCTAssertNoThrow(try repo.create(event: Event(type: .customEvent, body: "{}"))) + XCTAssertNoThrow(try repo.update(event: Event(type: .customEvent, body: "{}"))) + XCTAssertNoThrow(try repo.delete(event: Event(type: .customEvent, body: "{}"))) + XCTAssertNoThrow(try repo.removeDeprecatedEventsIfNeeded()) + } + + func test_CountAlwaysZero() throws { + try repo.erase() + XCTAssertEqual(try repo.countEvents(), 0) + + try repo.create(event: Event(type: .installed, body: "{}")) + XCTAssertEqual(try repo.countEvents(), 0) + + try repo.update(event: Event(type: .installed, body: "{}")) + XCTAssertEqual(try repo.countEvents(), 0) + + try repo.delete(event: Event(type: .installed, body: "{}")) + XCTAssertEqual(try repo.countEvents(), 0) + } + + func test_ReadAlwaysNil() throws { + let e = Event(type: .customEvent, body: "{}") + try repo.create(event: e) + XCTAssertNil(try repo.readEvent(by: e.transactionId)) + XCTAssertNil(try repo.readEvent(by: "non-existing")) + } + + func test_QueryAlwaysEmpty() throws { + XCTAssertTrue(try repo.query(fetchLimit: 10).isEmpty) + XCTAssertTrue(try repo.query(fetchLimit: 10, retryDeadline: 1).isEmpty) + } + + func test_DeprecatedCountersAndCleanup() throws { + XCTAssertEqual(try repo.countDeprecatedEvents(), 0) + XCTAssertNoThrow(try repo.removeDeprecatedEventsIfNeeded()) + XCTAssertEqual(try repo.countDeprecatedEvents(), 0) + } + + func test_MetadataIgnored() { + repo.infoUpdateVersion = 123 + repo.installVersion = 456 + repo.instanceId = "abc" + + XCTAssertNil(repo.infoUpdateVersion) + XCTAssertNil(repo.installVersion) + XCTAssertNil(repo.instanceId) + } + + func test_OnObjectsDidChange_NotFired() throws { + var fired = false + repo.onObjectsDidChange = { fired = true } + + try repo.create(event: Event(type: .installed, body: "{}")) + try repo.update(event: Event(type: .installed, body: "{}")) + try repo.delete(event: Event(type: .installed, body: "{}")) + try repo.erase() + + XCTAssertFalse(fired, "Noop should not trigger onObjectsDidChange") + } + + func test_ReadProperties_NoCrash() { + _ = repo.limit + _ = repo.deprecatedLimit + _ = repo.lifeLimitDate + } +} diff --git a/MindboxTests/Database/MBDatabaseRepositoryMemoryWarningTests.swift b/MindboxTests/Database/MBDatabaseRepositoryMemoryWarningTests.swift new file mode 100644 index 000000000..7e94bd778 --- /dev/null +++ b/MindboxTests/Database/MBDatabaseRepositoryMemoryWarningTests.swift @@ -0,0 +1,98 @@ +// +// MBDatabaseRepositoryMemoryWarningTests.swift +// MindboxTests +// +// Created by Sergei Semko on 9/25/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import XCTest +import CoreData +import UIKit +@testable import Mindbox + +final class MBDatabaseRepositoryMemoryWarningTests: XCTestCase { + + var eventGenerator: EventGenerator! + var dbLoader: DatabaseLoaderProtocol! + + override func setUp() { + super.setUp() + eventGenerator = EventGenerator() + dbLoader = DI.injectOrFail(DatabaseLoaderProtocol.self) + } + + override func tearDown() { + eventGenerator = nil + dbLoader = nil + super.tearDown() + } + + func test_InMemory_PrunesAll_OnMemoryWarning() throws { + let container = try dbLoader.makeInMemoryContainer() + let databaseRepository = try MBDatabaseRepository(persistentContainer: container) + + let events = eventGenerator.generateEvents(count: 50) + try events.forEach { try databaseRepository.create(event: $0) } + XCTAssertEqual(try databaseRepository.countEvents(), 50, "Precondition: 50 events stored in-memory") + + let exp = expectation(description: "onObjectsDidChange fired after prune") + databaseRepository.onObjectsDidChange = { exp.fulfill() } + + NotificationCenter.default.post( + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + + wait(for: [exp], timeout: 2.0) + + XCTAssertEqual(try databaseRepository.countEvents(), 0, "All in-memory events must be pruned on memory warning") + } + + func test_SQLite_IgnoresMemoryWarning() throws { + let container = try dbLoader.loadPersistentContainer() + let databaseRepository = try MBDatabaseRepository(persistentContainer: container) + + let events = eventGenerator.generateEvents(count: 20) + try events.forEach { try databaseRepository.create(event: $0) } + let before = try databaseRepository.countEvents() + + let inverted = expectation(description: "onObjectsDidChange should NOT fire for SQLite") + inverted.isInverted = true + databaseRepository.onObjectsDidChange = { inverted.fulfill() } + + NotificationCenter.default.post( + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + + wait(for: [inverted], timeout: 0.5) + + let after = try databaseRepository.countEvents() + XCTAssertEqual(before, after, "SQLite store must ignore memory warning pruning logic") + } + + func test_InMemory_PruneIsIdempotent_WhenWarningsBurst() throws { + let container = try dbLoader.makeInMemoryContainer() + let databaseRepository = try MBDatabaseRepository(persistentContainer: container) + + let events = eventGenerator.generateEvents(count: 30) + try events.forEach { try databaseRepository.create(event: $0) } + XCTAssertEqual(try databaseRepository.countEvents(), 30) + + let exp = expectation(description: "prune completed once") + exp.expectedFulfillmentCount = 1 + databaseRepository.onObjectsDidChange = { exp.fulfill() } + + for _ in 0..<5 { + NotificationCenter.default.post( + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + } + + wait(for: [exp], timeout: 2.0) + XCTAssertEqual(try databaseRepository.countEvents(), 0, "After burst of warnings, repository must end up empty") + } +} + diff --git a/MindboxTests/Database/MockDatabaseRepository.swift b/MindboxTests/Database/MockDatabaseRepository.swift index 8627e427d..6610a8576 100644 --- a/MindboxTests/Database/MockDatabaseRepository.swift +++ b/MindboxTests/Database/MockDatabaseRepository.swift @@ -23,53 +23,17 @@ final class MockDatabaseRepository: MBDatabaseRepository { createsDeprecated ? Date() : super.lifeLimitDate } - /// For in-memory tests + /// Creates a Core Data stack for in-memory tests or persistent storage. + /// + /// - Parameter inMemory: Pass `true` to use an in-memory store (good for unit tests). + /// - Throws: Errors thrown while creating the underlying ``NSPersistentContainer``. + /// - Note: Useful for unit tests to avoid writing to disk. + /// - See also: + /// - [Setting up a Core Data store for unit tests (Donny Wals)](https://www.donnywals.com/setting-up-a-core-data-store-for-unit-tests/) convenience init(inMemory: Bool) throws { - if inMemory { - let bundle = Bundle(for: CDEvent.self) - guard let model = NSManagedObjectModel.mergedModel(from: [bundle]) else { - fatalError("Could not find Core Data model in bundle") - } - let container = NSPersistentContainer( - name: Constants.Database.mombName, - managedObjectModel: model - ) - let desc = NSPersistentStoreDescription() - // https://www.donnywals.com/setting-up-a-core-data-store-for-unit-tests/ - // Instead of `desc.url = URL(fileURLWithPath: "/dev/null")` - desc.type = NSInMemoryStoreType - container.persistentStoreDescriptions = [desc] - - var loadError: Error? - container.loadPersistentStores { _, error in - loadError = error - } - - precondition(loadError == nil, "in-memory store didn't download: \(String(describing: loadError))") - - try self.init(persistentContainer: container) - } else { - let loader = DI.injectOrFail(DataBaseLoader.self) - let container = try loader.loadPersistentContainer() - try self.init(persistentContainer: container) - } - } - - override func erase() throws { - let bg = persistentContainer.newBackgroundContext() - try bg.mindboxPerformAndWait { - let fetch: NSFetchRequest = NSFetchRequest(entityName: "CDEvent") - let all = try bg.fetch(fetch) - for e in all { - bg.delete(e) - } - try bg.save() - } - - installVersion = nil - infoUpdateVersion = nil - - _ = try countEvents() + let loader = DI.injectOrFail(DatabaseLoaderProtocol.self) + let container: NSPersistentContainer = inMemory ? try loader.makeInMemoryContainer() : try loader.loadPersistentContainer() + try self.init(persistentContainer: container) } } diff --git a/MindboxTests/Database/MockDatabaseRepositoryTests.swift b/MindboxTests/Database/MockDatabaseRepositoryTests.swift index 3d7764527..5816ccc30 100644 --- a/MindboxTests/Database/MockDatabaseRepositoryTests.swift +++ b/MindboxTests/Database/MockDatabaseRepositoryTests.swift @@ -29,7 +29,7 @@ final class MockDatabaseRepositoryTests: XCTestCase { } func testRegisterMBDBRepoIsMock() { - XCTAssert(DI.injectOrFail(MBDatabaseRepository.self) is MockDatabaseRepository) + XCTAssert(DI.injectOrFail(DatabaseRepositoryProtocol.self) is MockDatabaseRepository) } func testCreateReadDeleteEvent() throws { @@ -37,14 +37,14 @@ final class MockDatabaseRepositoryTests: XCTestCase { // creation try repo.create(event: event) // reading - let cd = try XCTUnwrap(repo.read(by: event.transactionId), + let cd = try XCTUnwrap(repo.readEvent(by: event.transactionId), "Не смогли прочитать только что созданное событие") XCTAssertEqual(cd.transactionId, event.transactionId) XCTAssertEqual(cd.body, event.body) // deletion try repo.delete(event: event) - let afterDelete = try repo.read(by: event.transactionId) + let afterDelete = try repo.readEvent(by: event.transactionId) XCTAssertNil(afterDelete, "Событие должно быть удалено") } @@ -129,7 +129,7 @@ final class MockDatabaseRepositoryTests: XCTestCase { func testProductionRepositoryUsesSQLiteStore() throws { // GIVEN // We load the real container in the same way as in the application - let loader = DI.injectOrFail(DataBaseLoader.self) + let loader = DI.injectOrFail(DatabaseLoaderProtocol.self) let sqlContainer = try loader.loadPersistentContainer() let prodRepo = try MBDatabaseRepository(persistentContainer: sqlContainer) diff --git a/MindboxTests/EventRepository/EventRepositoryTestCase.swift b/MindboxTests/EventRepository/EventRepositoryTestCase.swift index d6dbe9444..c0b71399e 100644 --- a/MindboxTests/EventRepository/EventRepositoryTestCase.swift +++ b/MindboxTests/EventRepository/EventRepositoryTestCase.swift @@ -22,7 +22,7 @@ class EventRepositoryTestCase: XCTestCase { coreController = DI.injectOrFail(CoreController.self) controllerQueue = coreController.controllerQueue persistenceStorage.reset() - let databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + let databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) try! databaseRepository.erase() } diff --git a/MindboxTests/GuaranteedDelivery/GuaranteedDeliveryTestCase.swift b/MindboxTests/GuaranteedDelivery/GuaranteedDeliveryTestCase.swift index 8420b0b0b..8c45d642e 100644 --- a/MindboxTests/GuaranteedDelivery/GuaranteedDeliveryTestCase.swift +++ b/MindboxTests/GuaranteedDelivery/GuaranteedDeliveryTestCase.swift @@ -14,7 +14,7 @@ import XCTest class GuaranteedDeliveryTestCase: XCTestCase { - var databaseRepository: MBDatabaseRepository! + var databaseRepository: DatabaseRepositoryProtocol! var guaranteedDeliveryManager: GuaranteedDeliveryManager! var persistenceStorage: PersistenceStorage! var eventGenerator: EventGenerator! @@ -24,7 +24,7 @@ class GuaranteedDeliveryTestCase: XCTestCase { super.setUp() Mindbox.logger.logLevel = .none - databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) guaranteedDeliveryManager = DI.injectOrFail(GuaranteedDeliveryManager.self) persistenceStorage = DI.injectOrFail(PersistenceStorage.self) eventGenerator = EventGenerator() @@ -51,7 +51,7 @@ class GuaranteedDeliveryTestCase: XCTestCase { let retryDeadline: TimeInterval = 3 guaranteedDeliveryManager = GuaranteedDeliveryManager( persistenceStorage: DI.injectOrFail(PersistenceStorage.self), - databaseRepository: DI.injectOrFail(MBDatabaseRepository.self), + databaseRepository: DI.injectOrFail(DatabaseRepositoryProtocol.self), eventRepository: DI.injectOrFail(EventRepository.self), retryDeadline: retryDeadline ) @@ -86,6 +86,18 @@ class GuaranteedDeliveryTestCase: XCTestCase { XCTAssertEqual(event.type, mockEvent.type, "Types should be equal") XCTAssertEqual(event.isRetry, mockEvent.isRetry, "Flags `isRetry` should be equal") XCTAssertEqual(event.dateTimeOffset, mockEvent.dateTimeOffset, "Date time offsets should be equal") + XCTAssertEqual(event.retryTimestamp, mockEvent.retryTimestamp, "Retry timestamps should be equal") + } + + func testEventRetryTimestampLogic() { + let type: Event.Operation = .installed + let body = UUID().uuidString + + let event: EventProtocol = Event(type: type, body: body) + let mockEvent: EventProtocol = MockEvent(type: type, body: body, retryTimestamp: Date().timeIntervalSince1970) + + XCTAssertFalse(event.isRetry, "If `retryTimestamp` is zero, `isRetry` must be false") + XCTAssertTrue(mockEvent.isRetry, "if `retryTimestamp` is NOT zero, `isRetry` must be true") } func testDateTimeOffset_WhenNotRetry_isZero() { @@ -108,7 +120,7 @@ class GuaranteedDeliveryTestCase: XCTestCase { type: .installed, body: "baz", enqueueTimeStamp: fixedEnqueue, - isRetry: true, + retryTimestamp: 100, clock: clock ) @@ -123,7 +135,7 @@ class GuaranteedDeliveryTestCase: XCTestCase { let retryDeadline: TimeInterval = 2 guaranteedDeliveryManager = GuaranteedDeliveryManager( persistenceStorage: DI.injectOrFail(PersistenceStorage.self), - databaseRepository: DI.injectOrFail(MBDatabaseRepository.self), + databaseRepository: DI.injectOrFail(DatabaseRepositoryProtocol.self), eventRepository: DI.injectOrFail(EventRepository.self), retryDeadline: retryDeadline ) diff --git a/MindboxTests/Helpers/EventGenerator.swift b/MindboxTests/Helpers/EventGenerator.swift index bc9a92f2a..c3eb96f49 100644 --- a/MindboxTests/Helpers/EventGenerator.swift +++ b/MindboxTests/Helpers/EventGenerator.swift @@ -32,7 +32,7 @@ struct EventGenerator { return MockEvent( type: .installed, body: UUID().uuidString, - isRetry: true + retryTimestamp: 100 ) } } diff --git a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappMapperTests.swift b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappMapperTests.swift index 19dbeb997..5124b9a82 100644 --- a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappMapperTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappMapperTests.swift @@ -23,7 +23,7 @@ class InappRemainingTargetingTests: XCTestCase { SessionTemporaryStorage.shared.erase() targetingChecker = DI.injectOrFail(InAppTargetingCheckerProtocol.self) - let databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + let databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) try! databaseRepository.erase() mockDataFacade = DI.injectOrFail(InAppConfigurationDataFacadeProtocol.self) as? MockInAppConfigurationDataFacade diff --git a/MindboxTests/MindboxTests.swift b/MindboxTests/MindboxTests.swift index baee0d1af..9dbdb1b89 100644 --- a/MindboxTests/MindboxTests.swift +++ b/MindboxTests/MindboxTests.swift @@ -23,13 +23,13 @@ class MindboxTests: XCTestCase { var coreController: CoreController! var controllerQueue = DispatchQueue(label: "test-core-controller-queue") var persistenceStorage: PersistenceStorage! - var databaseRepository: MBDatabaseRepository! + var databaseRepository: DatabaseRepositoryProtocol! override func setUp() { super.setUp() persistenceStorage = DI.injectOrFail(PersistenceStorage.self) persistenceStorage.reset() - databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) try! databaseRepository.erase() Mindbox.shared.assembly() Mindbox.shared.coreController?.controllerQueue = self.controllerQueue @@ -75,7 +75,7 @@ class MindboxTests: XCTestCase { XCTAssert(deviceUUID == deviceUUID2) persistenceStorage.reset() - let databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + let databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) try! databaseRepository.erase() coreController = DI.injectOrFail(CoreController.self) diff --git a/MindboxTests/Mock/MockEvent.swift b/MindboxTests/Mock/MockEvent.swift index 604d4b385..8c8ce54e1 100644 --- a/MindboxTests/Mock/MockEvent.swift +++ b/MindboxTests/Mock/MockEvent.swift @@ -30,7 +30,9 @@ struct MockEvent: EventProtocol { let type: Event.Operation - let isRetry: Bool + var isRetry: Bool { !retryTimestamp.isZero } + + let retryTimestamp: Double let body: String @@ -40,7 +42,7 @@ struct MockEvent: EventProtocol { self.type = type self.body = body self.serialNumber = nil - self.isRetry = false + self.retryTimestamp = 0 self.clock = SystemClock() } @@ -61,7 +63,7 @@ struct MockEvent: EventProtocol { self.type = operation self.body = body self.serialNumber = event.objectID.uriRepresentation().lastPathComponent - self.isRetry = !event.retryTimestamp.isZero + self.retryTimestamp = event.retryTimestamp } } @@ -69,7 +71,7 @@ extension MockEvent { init(type: Event.Operation, body: String, enqueueTimeStamp: Double = Date().timeIntervalSince1970, - isRetry: Bool = false, + retryTimestamp: Double = 0, clock: Clock = SystemClock(), serialNumber: String? = nil, transactionId: String = UUID().uuidString) { @@ -79,6 +81,6 @@ extension MockEvent { self.type = type self.body = body self.serialNumber = serialNumber - self.isRetry = isRetry + self.retryTimestamp = retryTimestamp } } diff --git a/MindboxTests/Versioning/VersioningTestCase.swift b/MindboxTests/Versioning/VersioningTestCase.swift index cd64ba55f..2906ae0f6 100644 --- a/MindboxTests/Versioning/VersioningTestCase.swift +++ b/MindboxTests/Versioning/VersioningTestCase.swift @@ -15,7 +15,7 @@ class VersioningTestCase: XCTestCase { private var queues: [DispatchQueue] = [] var persistenceStorage: PersistenceStorage! - var databaseRepository: MBDatabaseRepository! + var databaseRepository: DatabaseRepositoryProtocol! var guaranteedDeliveryManager: GuaranteedDeliveryManager! override func setUp() { @@ -24,7 +24,7 @@ class VersioningTestCase: XCTestCase { persistenceStorage = DI.injectOrFail(PersistenceStorage.self) persistenceStorage.reset() - databaseRepository = DI.injectOrFail(MBDatabaseRepository.self) + databaseRepository = DI.injectOrFail(DatabaseRepositoryProtocol.self) try! databaseRepository.erase() guaranteedDeliveryManager = DI.injectOrFail(GuaranteedDeliveryManager.self)