From cbb9b74eaae6c6253cffd722086a707967675358 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Thu, 18 Dec 2025 12:29:52 -0300 Subject: [PATCH 1/3] New methods for transactions --- .github/workflows/sonar.yml | 5 +-- Split.xcodeproj/project.pbxproj | 4 ++ Split/Storage/CoreDataHelper.swift | 18 ++++++++ .../Storage/GeneralInfo/GeneralInfoDao.swift | 15 +++++++ Split/Storage/Splits/SplitDao.swift | 35 +++++++++++++++ .../Fake/Storage/CoreDataHelperStub.swift | 43 +++++++++++++++++++ .../Fake/Storage/GeneralInfoDaoStub.swift | 4 ++ 7 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 SplitTests/Fake/Storage/CoreDataHelperStub.swift diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 6bab58320..14c7321e2 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -4,10 +4,9 @@ on: pull_request: branches: - "**" - - merge: + push: branches: - - "**" + - master # Cancel in-progress runs when a new workflow with the same group is triggered concurrency: diff --git a/Split.xcodeproj/project.pbxproj b/Split.xcodeproj/project.pbxproj index efe4edcdb..ef3bf17f6 100644 --- a/Split.xcodeproj/project.pbxproj +++ b/Split.xcodeproj/project.pbxproj @@ -1096,6 +1096,7 @@ C53207E92EF44A2100418BB1 /* PersistenceBreakerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207E82EF44A2100418BB1 /* PersistenceBreakerTests.swift */; }; C53207EB2EF44A2F00418BB1 /* PersistenceBreaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207EA2EF44A2F00418BB1 /* PersistenceBreaker.swift */; }; C53207EC2EF44A2F00418BB1 /* PersistenceBreaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207EA2EF44A2F00418BB1 /* PersistenceBreaker.swift */; }; + C53207EE2EF452C000418BB1 /* CoreDataHelperStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207ED2EF452C000418BB1 /* CoreDataHelperStub.swift */; }; C539CAB62D88C1F10050C732 /* RuleBasedSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CAB52D88C1F10050C732 /* RuleBasedSegment.swift */; }; C539CABE2D88C7410050C732 /* PersistentRuleBasedSegmentsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CABC2D88C7410050C732 /* PersistentRuleBasedSegmentsStorage.swift */; }; C539CABF2D88C7410050C732 /* RuleBasedSegmentsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CABD2D88C7410050C732 /* RuleBasedSegmentsStorage.swift */; }; @@ -2006,6 +2007,7 @@ C52C572A2EEB41450064D049 /* EncryptionKeyValidationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionKeyValidationTest.swift; sourceTree = ""; }; C53207E82EF44A2100418BB1 /* PersistenceBreakerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceBreakerTests.swift; sourceTree = ""; }; C53207EA2EF44A2F00418BB1 /* PersistenceBreaker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceBreaker.swift; sourceTree = ""; }; + C53207ED2EF452C000418BB1 /* CoreDataHelperStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelperStub.swift; sourceTree = ""; }; C539CAB52D88C1F10050C732 /* RuleBasedSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBasedSegment.swift; sourceTree = ""; }; C539CABC2D88C7410050C732 /* PersistentRuleBasedSegmentsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentRuleBasedSegmentsStorage.swift; sourceTree = ""; }; C539CABD2D88C7410050C732 /* RuleBasedSegmentsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBasedSegmentsStorage.swift; sourceTree = ""; }; @@ -2727,6 +2729,7 @@ 5905D4E6255B23C8006DA3B1 /* Storage */ = { isa = PBXGroup; children = ( + C53207ED2EF452C000418BB1 /* CoreDataHelperStub.swift */, C539CAC22D88C7570050C732 /* PersistentRuleBasedSegmentsStorageStub.swift */, C539CAC32D88C7570050C732 /* RuleBasedSegmentsStorageStub.swift */, C5A501D82D88A7E900206F82 /* RuleBasedSegmentDaoStub.swift */, @@ -4819,6 +4822,7 @@ 5982D938219F57BE00230F44 /* FileHelper.swift in Sources */, 95B180272763DA0E002DC9DF /* HttpTelemetryConfigRecorderTest.swift in Sources */, C5977C202BF29F5B003E293A /* EqualToSemverMatcherTest.swift in Sources */, + C53207EE2EF452C000418BB1 /* CoreDataHelperStub.swift in Sources */, C5A7D5532DD672CF0081D190 /* RuleBasedSegmentsIntegrationTest.swift in Sources */, 59ED408424EAB8C900EF7B09 /* PushNotificationManagerTest.swift in Sources */, 95F7BC292C46A4C800C5F2E4 /* SecurityHelper.swift in Sources */, diff --git a/Split/Storage/CoreDataHelper.swift b/Split/Storage/CoreDataHelper.swift index d996f87e1..713b8e9fc 100644 --- a/Split/Storage/CoreDataHelper.swift +++ b/Split/Storage/CoreDataHelper.swift @@ -64,6 +64,24 @@ class CoreDataHelper { } } + /// Save with error handling. Throws errors to caller + /// Used for transactional operations that need to handle persistence failures + func saveWithErrorHandling() throws { + var thrownError: Error? + managedObjectContext.performAndWait { + do { + if self.managedObjectContext.hasChanges { + try self.managedObjectContext.save() + } + } catch { + thrownError = error + } + } + if let error = thrownError { + throw error + } + } + func generateId() -> String { return UUID().uuidString } diff --git a/Split/Storage/GeneralInfo/GeneralInfoDao.swift b/Split/Storage/GeneralInfo/GeneralInfoDao.swift index 9753311bf..097fcd494 100644 --- a/Split/Storage/GeneralInfo/GeneralInfoDao.swift +++ b/Split/Storage/GeneralInfo/GeneralInfoDao.swift @@ -28,6 +28,10 @@ protocol GeneralInfoDao { func stringValue(info: GeneralInfo) -> String? func longValue(info: GeneralInfo) -> Int64? func delete(info: GeneralInfo) + + /// Synchronous update for use in transactions. + /// Caller must call coreDataHelper.saveWithErrorHandling() after all ops + func transactionalUpdate(info: GeneralInfo, longValue: Int64) } class CoreDataGeneralInfoDao: BaseCoreDataDao, GeneralInfoDao { @@ -86,6 +90,17 @@ class CoreDataGeneralInfoDao: BaseCoreDataDao, GeneralInfoDao { } } + /// Synchronous update that does NOT save. Caller must save. For use in transactions + func transactionalUpdate(info: GeneralInfo, longValue: Int64) { + if let obj = get(for: info) ?? coreDataHelper.create(entity: .generalInfo) as? GeneralInfoEntity { + obj.name = info.rawValue + obj.stringValue = "" + obj.longValue = longValue + obj.updatedAt = Date().unixTimestamp() + // Not saving. Caller will save the entire transaction + } + } + private func update(info: GeneralInfo, stringValue: String?, longValue: Int64?) { if let obj = get(for: info) ?? coreDataHelper.create(entity: .generalInfo) as? GeneralInfoEntity { obj.name = info.rawValue diff --git a/Split/Storage/Splits/SplitDao.swift b/Split/Storage/Splits/SplitDao.swift index cae62e01a..bb2403be1 100644 --- a/Split/Storage/Splits/SplitDao.swift +++ b/Split/Storage/Splits/SplitDao.swift @@ -16,6 +16,14 @@ protocol SplitDao { func delete(_ splits: [String]) func deleteAll() func syncInsertOrUpdate(split: Split) + + /// Synchronous insert/update for use in transactions + /// Caller must call coreDataHelper.saveWithErrorHandling() + func transactionalInsertOrUpdate(splits: [Split]) throws + + /// Synchronous delete for use in transactions + /// Caller must call coreDataHelper.saveWithErrorHandling() + func transactionalDelete(_ splitNames: [String]) } class CoreDataSplitDao: BaseCoreDataDao, SplitDao { @@ -112,6 +120,33 @@ class CoreDataSplitDao: BaseCoreDataDao, SplitDao { } } + /// Synchronous insert/update that does NOT save + func transactionalInsertOrUpdate(splits: [Split]) throws { + let parsed = self.encoder.encode(splits) + for (name, json) in parsed { + if let obj = self.getBy(name: name) ?? self.coreDataHelper.create(entity: .split) as? SplitEntity { + obj.name = name + obj.body = json + obj.updatedAt = Date.now() + // Do NOT save here. Caller should save the entire transaction + } + } + } + + /// Synchronous delete that does NOT save + func transactionalDelete(_ splitNames: [String]) { + if splitNames.count == 0 { + return + } + + var names = splitNames + if let cipher = self.cipher { + names = splitNames.map { cipher.encrypt($0) ?? $0 } + } + self.coreDataHelper.delete(entity: .split, by: "name", values: names) + // Do NOT save here. Caller should save the entire transaction + } + private func insertOrUpdate(_ split: Split) { if let splitName = cipher?.encrypt(split.name) ?? split.name, let obj = self.getBy(name: splitName) ?? self.coreDataHelper.create(entity: .split) as? SplitEntity { diff --git a/SplitTests/Fake/Storage/CoreDataHelperStub.swift b/SplitTests/Fake/Storage/CoreDataHelperStub.swift new file mode 100644 index 000000000..0dd736f53 --- /dev/null +++ b/SplitTests/Fake/Storage/CoreDataHelperStub.swift @@ -0,0 +1,43 @@ +// +// CoreDataHelperStub.swift +// SplitTests +// + +import Foundation +import CoreData +@testable import Split + +class CoreDataHelperStub: CoreDataHelper { + + var shouldFailOnSave = false + var saveError: Error = NSError(domain: "TestCoreData", code: 500, userInfo: [NSLocalizedDescriptionKey: "Simulated save failure"]) + + init() { + let model = NSManagedObjectModel() + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model) + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.persistentStoreCoordinator = coordinator + + super.init(managedObjectContext: context, persistentCoordinator: coordinator) + } + + override func performAndWait(_ operation: () -> Void) { + operation() + } + + override func perform(_ operation: @escaping () -> Void) { + operation() + } + + override func save() { + // No-op for stubs + } + + override func saveWithErrorHandling() throws { + if shouldFailOnSave { + throw saveError + } + // Success + } +} + diff --git a/SplitTests/Fake/Storage/GeneralInfoDaoStub.swift b/SplitTests/Fake/Storage/GeneralInfoDaoStub.swift index 90db7dcd2..2ba3611ae 100644 --- a/SplitTests/Fake/Storage/GeneralInfoDaoStub.swift +++ b/SplitTests/Fake/Storage/GeneralInfoDaoStub.swift @@ -34,4 +34,8 @@ class GeneralInfoDaoStub: GeneralInfoDao { updatedString.removeValue(forKey: info.rawValue) updatedLong.removeValue(forKey: info.rawValue) } + + func transactionalUpdate(info: GeneralInfo, longValue: Int64) { + updatedLong[info.rawValue] = longValue + } } From 57cce4351b2e67b2501a1cd5884115a28d5b007c Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Thu, 18 Dec 2025 12:36:47 -0300 Subject: [PATCH 2/3] CoreDataHelper test --- Split.xcodeproj/project.pbxproj | 4 ++ SplitTests/Fake/Storage/SplitDaoStub.swift | 8 +++ .../Fake/Storage/SplitDatabaseStub.swift | 8 ++- SplitTests/Storage/CoreDataHelperTests.swift | 69 +++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 SplitTests/Storage/CoreDataHelperTests.swift diff --git a/Split.xcodeproj/project.pbxproj b/Split.xcodeproj/project.pbxproj index ef3bf17f6..1adce743b 100644 --- a/Split.xcodeproj/project.pbxproj +++ b/Split.xcodeproj/project.pbxproj @@ -1097,6 +1097,7 @@ C53207EB2EF44A2F00418BB1 /* PersistenceBreaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207EA2EF44A2F00418BB1 /* PersistenceBreaker.swift */; }; C53207EC2EF44A2F00418BB1 /* PersistenceBreaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207EA2EF44A2F00418BB1 /* PersistenceBreaker.swift */; }; C53207EE2EF452C000418BB1 /* CoreDataHelperStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207ED2EF452C000418BB1 /* CoreDataHelperStub.swift */; }; + C53207F12EF456AF00418BB1 /* CoreDataHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207F02EF456AF00418BB1 /* CoreDataHelperTests.swift */; }; C539CAB62D88C1F10050C732 /* RuleBasedSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CAB52D88C1F10050C732 /* RuleBasedSegment.swift */; }; C539CABE2D88C7410050C732 /* PersistentRuleBasedSegmentsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CABC2D88C7410050C732 /* PersistentRuleBasedSegmentsStorage.swift */; }; C539CABF2D88C7410050C732 /* RuleBasedSegmentsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CABD2D88C7410050C732 /* RuleBasedSegmentsStorage.swift */; }; @@ -2008,6 +2009,7 @@ C53207E82EF44A2100418BB1 /* PersistenceBreakerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceBreakerTests.swift; sourceTree = ""; }; C53207EA2EF44A2F00418BB1 /* PersistenceBreaker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceBreaker.swift; sourceTree = ""; }; C53207ED2EF452C000418BB1 /* CoreDataHelperStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelperStub.swift; sourceTree = ""; }; + C53207F02EF456AF00418BB1 /* CoreDataHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelperTests.swift; sourceTree = ""; }; C539CAB52D88C1F10050C732 /* RuleBasedSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBasedSegment.swift; sourceTree = ""; }; C539CABC2D88C7410050C732 /* PersistentRuleBasedSegmentsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentRuleBasedSegmentsStorage.swift; sourceTree = ""; }; C539CABD2D88C7410050C732 /* RuleBasedSegmentsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBasedSegmentsStorage.swift; sourceTree = ""; }; @@ -2685,6 +2687,7 @@ 5905D4E3255B2373006DA3B1 /* Storage */ = { isa = PBXGroup; children = ( + C53207F02EF456AF00418BB1 /* CoreDataHelperTests.swift */, C53207E82EF44A2100418BB1 /* PersistenceBreakerTests.swift */, C52C572A2EEB41450064D049 /* EncryptionKeyValidationTest.swift */, 599407DF22403BE9003B85CC /* SplitsStorageTrafficTypesTests.swift */, @@ -4634,6 +4637,7 @@ 952E26752833FF3F0015D633 /* UniqueKeyDaoStub.swift in Sources */, 59ED408F24F06EC100EF7B09 /* TimersManagerTest.swift in Sources */, 59D84BE7221AE775003DA248 /* LocalhostManagerTests.swift in Sources */, + C53207F12EF456AF00418BB1 /* CoreDataHelperTests.swift in Sources */, C539CAE22D9477770050C732 /* PropertyValidatorStub.swift in Sources */, 955B596C2816BC0C00D105CD /* MultiClientEvaluationTest.swift in Sources */, 59FB7C35220329B900ECC96A /* SplitFactoryBuilderTests.swift in Sources */, diff --git a/SplitTests/Fake/Storage/SplitDaoStub.swift b/SplitTests/Fake/Storage/SplitDaoStub.swift index 7d6bd0aaf..b0c32f131 100644 --- a/SplitTests/Fake/Storage/SplitDaoStub.swift +++ b/SplitTests/Fake/Storage/SplitDaoStub.swift @@ -38,4 +38,12 @@ class SplitDaoStub: SplitDao { func deleteAll() { deleteAllCalled = true } + + func transactionalInsertOrUpdate(splits: [Split]) throws { + insertedSplits = splits + } + + func transactionalDelete(_ splitNames: [String]) { + deletedSplits = splitNames + } } diff --git a/SplitTests/Fake/Storage/SplitDatabaseStub.swift b/SplitTests/Fake/Storage/SplitDatabaseStub.swift index 6f1eab591..193e47f6f 100644 --- a/SplitTests/Fake/Storage/SplitDatabaseStub.swift +++ b/SplitTests/Fake/Storage/SplitDatabaseStub.swift @@ -37,7 +37,7 @@ struct CoreDataDaoProviderMock: DaoProvider { var ruleBasedSegmentDao: RuleBasedSegmentDao = RuleBasedSegmentDaoStub() } -class SplitDatabaseStub: SplitDatabase { +class SplitDatabaseStub: SplitDatabase, TestSplitDatabase { var splitDao: SplitDao var mySegmentsDao: MySegmentsDao @@ -51,7 +51,10 @@ class SplitDatabaseStub: SplitDatabase { var uniqueKeyDao: UniqueKeyDao var ruleBasedSegmentDao: RuleBasedSegmentDao - init(daoProvider: DaoProvider) { + // TestSplitDatabase conformance + var coreDataHelper: CoreDataHelper + + init(daoProvider: DaoProvider, coreDataHelper: CoreDataHelper? = nil) { self.eventDao = daoProvider.eventDao self.impressionDao = daoProvider.impressionDao self.impressionsCountDao = daoProvider.impressionsCountDao @@ -63,5 +66,6 @@ class SplitDatabaseStub: SplitDatabase { self.uniqueKeyDao = daoProvider.uniqueKeyDao self.hashedImpressionDao = daoProvider.hashedImpressionDao self.ruleBasedSegmentDao = daoProvider.ruleBasedSegmentDao + self.coreDataHelper = coreDataHelper ?? CoreDataHelperStub() } } diff --git a/SplitTests/Storage/CoreDataHelperTests.swift b/SplitTests/Storage/CoreDataHelperTests.swift new file mode 100644 index 000000000..ff6ae6c20 --- /dev/null +++ b/SplitTests/Storage/CoreDataHelperTests.swift @@ -0,0 +1,69 @@ +// +// CoreDataHelperTests.swift +// SplitTests +// + +import Foundation +import XCTest +import CoreData +@testable import Split + +class CoreDataHelperTests: XCTestCase { + + var coreDataHelper: CoreDataHelper! + + override func setUp() { + let queue = DispatchQueue(label: "coredata helper test") + coreDataHelper = IntegrationCoreDataHelper.get(databaseName: "test", dispatchQueue: queue) + } + + func testSaveWithErrorHandlingSucceedsWhenChangesExist() { + coreDataHelper.performAndWait { + _ = self.coreDataHelper.create(entity: .generalInfo) + } + + XCTAssertNoThrow(try coreDataHelper.saveWithErrorHandling()) + } + + func testSaveWithErrorHandlingSucceedsWhenNoChanges() { + XCTAssertNoThrow(try coreDataHelper.saveWithErrorHandling()) + } + + func testSaveWithErrorHandlingPersistsData() { + coreDataHelper.performAndWait { + if let entity = self.coreDataHelper.create(entity: .generalInfo) as? GeneralInfoEntity { + entity.name = GeneralInfo.splitsChangeNumber.rawValue + entity.longValue = 12345 + } + } + + try? coreDataHelper.saveWithErrorHandling() + + let fetched = coreDataHelper.fetch(entity: .generalInfo) + XCTAssertEqual(1, fetched.count) + + if let entity = fetched.first as? GeneralInfoEntity { + XCTAssertEqual(12345, entity.longValue) + } else { + XCTFail("Expected GeneralInfoEntity") + } + } + + func testSaveWithErrorHandlingThrowsOnInvalidContext() { + let invalidContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + let invalidHelper = CoreDataHelper( + managedObjectContext: invalidContext, + persistentCoordinator: NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel()) + ) + + invalidContext.performAndWait { + let entity = NSEntityDescription() + entity.name = "TestEntity" + entity.managedObjectClassName = NSStringFromClass(NSManagedObject.self) + } + + // Context without persistent store should not throw when there are no changes + XCTAssertNoThrow(try invalidHelper.saveWithErrorHandling()) + } +} + From 977a3cdc01ed3bbde69fcf23904af29f83e93688 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Thu, 18 Dec 2025 12:51:37 -0300 Subject: [PATCH 3/3] Add missing tests --- SplitTests/Storage/CoreDataHelperTests.swift | 17 ++++- SplitTests/Storage/GeneralInfoDaoTests.swift | 41 ++++++++++ SplitTests/Storage/SplitDaoTest.swift | 79 ++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/SplitTests/Storage/CoreDataHelperTests.swift b/SplitTests/Storage/CoreDataHelperTests.swift index ff6ae6c20..c2c6bae8e 100644 --- a/SplitTests/Storage/CoreDataHelperTests.swift +++ b/SplitTests/Storage/CoreDataHelperTests.swift @@ -19,12 +19,27 @@ class CoreDataHelperTests: XCTestCase { func testSaveWithErrorHandlingSucceedsWhenChangesExist() { coreDataHelper.performAndWait { - _ = self.coreDataHelper.create(entity: .generalInfo) + if let entity = self.coreDataHelper.create(entity: .generalInfo) as? GeneralInfoEntity { + entity.name = "test_info" + entity.stringValue = "test_value" + } } XCTAssertNoThrow(try coreDataHelper.saveWithErrorHandling()) } + func testSaveWithErrorHandlingThrowsOnValidationError() { + coreDataHelper.performAndWait { + // Create entity without required 'name' field to trigger validation error + _ = self.coreDataHelper.create(entity: .generalInfo) + } + + XCTAssertThrowsError(try coreDataHelper.saveWithErrorHandling()) { error in + let nsError = error as NSError + XCTAssertEqual(NSValidationMissingMandatoryPropertyError, nsError.code) + } + } + func testSaveWithErrorHandlingSucceedsWhenNoChanges() { XCTAssertNoThrow(try coreDataHelper.saveWithErrorHandling()) } diff --git a/SplitTests/Storage/GeneralInfoDaoTests.swift b/SplitTests/Storage/GeneralInfoDaoTests.swift index 017c8daba..a22f8662e 100644 --- a/SplitTests/Storage/GeneralInfoDaoTests.swift +++ b/SplitTests/Storage/GeneralInfoDaoTests.swift @@ -63,6 +63,47 @@ class GeneralInfoDaoTest: XCTestCase { XCTAssertEqual(data, segmentsInUse) } + + func testTransactionalUpdateDoesNotSaveUntilCallerSaves() { + guard let coreDataDao = generalInfoDao as? CoreDataGeneralInfoDao else { + XCTFail("Expected CoreDataGeneralInfoDao") + return + } + + coreDataDao.coreDataHelper.performAndWait { + coreDataDao.transactionalUpdate(info: .splitsChangeNumber, longValue: 999) + } + + // Value should be in context but we need to save to persist + coreDataDao.coreDataHelper.save() + + let savedValue = generalInfoDao.longValue(info: .splitsChangeNumber) + XCTAssertEqual(999, savedValue) + } + + func testTransactionalUpdateUpdatesExistingValue() { + guard let coreDataDao = generalInfoDao as? CoreDataGeneralInfoDao else { + XCTFail("Expected CoreDataGeneralInfoDao") + return + } + + // Create initial value + generalInfoDao.update(info: .splitsUpdateTimestamp, longValue: 100) + + // Wait for async save to complete + let exp = expectation(description: "wait for save") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { exp.fulfill() } + wait(for: [exp], timeout: 1.0) + + // Transactionally update + coreDataDao.coreDataHelper.performAndWait { + coreDataDao.transactionalUpdate(info: .splitsUpdateTimestamp, longValue: 200) + } + coreDataDao.coreDataHelper.save() + + let updatedValue = generalInfoDao.longValue(info: .splitsUpdateTimestamp) + XCTAssertEqual(200, updatedValue) + } override func tearDown() { } diff --git a/SplitTests/Storage/SplitDaoTest.swift b/SplitTests/Storage/SplitDaoTest.swift index 3ca848235..9926c8e0b 100644 --- a/SplitTests/Storage/SplitDaoTest.swift +++ b/SplitTests/Storage/SplitDaoTest.swift @@ -154,6 +154,85 @@ class SplitDaoTest: XCTestCase { return (name: name, body: body) } + func testTransactionalInsertOrUpdateDoesNotSaveUntilCallerSaves() { + guard let coreDataDao = splitDao as? CoreDataSplitDao else { + XCTFail("Expected CoreDataSplitDao") + return + } + + let newSplits = [newSplit(name: "transactional_split_1", trafficType: "user")] + + coreDataDao.coreDataHelper.performAndWait { + try? coreDataDao.transactionalInsertOrUpdate(splits: newSplits) + } + + // Save manually + coreDataDao.coreDataHelper.save() + + let allSplits = splitDao.getAll() + let found = allSplits.first { $0.name == "transactional_split_1" } + + XCTAssertNotNil(found) + XCTAssertEqual("user", found?.trafficTypeName) + } + + func testTransactionalInsertOrUpdateUpdatesExisting() { + guard let coreDataDao = splitDao as? CoreDataSplitDao else { + XCTFail("Expected CoreDataSplitDao") + return + } + + // feat_0 was created in setUp + let updatedSplit = newSplit(name: "feat_0", trafficType: "updated_type") + + coreDataDao.coreDataHelper.performAndWait { + try? coreDataDao.transactionalInsertOrUpdate(splits: [updatedSplit]) + } + coreDataDao.coreDataHelper.save() + + let allSplits = splitDao.getAll() + let found = allSplits.first { $0.name == "feat_0" } + + XCTAssertNotNil(found) + XCTAssertEqual("updated_type", found?.trafficTypeName) + } + + func testTransactionalDeleteDoesNotSaveUntilCallerSaves() { + guard let coreDataDao = splitDao as? CoreDataSplitDao else { + XCTFail("Expected CoreDataSplitDao") + return + } + + let beforeCount = splitDao.getAll().count + + coreDataDao.coreDataHelper.performAndWait { + coreDataDao.transactionalDelete(["feat_0", "feat_1"]) + } + coreDataDao.coreDataHelper.save() + + let afterCount = splitDao.getAll().count + + XCTAssertEqual(beforeCount - 2, afterCount) + } + + func testTransactionalDeleteWithEmptyArrayDoesNothing() { + guard let coreDataDao = splitDao as? CoreDataSplitDao else { + XCTFail("Expected CoreDataSplitDao") + return + } + + let beforeCount = splitDao.getAll().count + + coreDataDao.coreDataHelper.performAndWait { + coreDataDao.transactionalDelete([]) + } + coreDataDao.coreDataHelper.save() + + let afterCount = splitDao.getAll().count + + XCTAssertEqual(beforeCount, afterCount) + } + private func createSplits() -> [Split] { return SplitTestHelper.createSplits(namePrefix: "feat_", count: 10) }