diff --git a/.gitignore b/.gitignore index 138face..e7b722d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,3 @@ Carthage # `pod install` in .travis.yml # # Pods/ - - -.build diff --git a/README.md b/README.md index 36b4e96..a848567 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ If you are using `Coadble` protocol from Swift 4 and needs an easy way to store - [x] Documents - [x] User Default - [ ] CloudKit -- [ ] NSUbiquitousKeyValueStore +- [x] NSUbiquitousKeyValueStore - [ ] Name Spaced Stores - [x] Comprehensive Unit and Integration Test Coverage - [ ] Complete Documentation diff --git a/Storage/Classes/CodableLocation.swift b/Storage/Classes/CodableLocation.swift deleted file mode 100644 index 359a3d4..0000000 --- a/Storage/Classes/CodableLocation.swift +++ /dev/null @@ -1,35 +0,0 @@ -import CoreLocation - -struct CodableLocation: Codable { - let latitude: Double - let longitude: Double - let altitude: Double - let horizontalAccuracy: Double - let verticalAccuracy: Double - let course: Double - let speed: Double - let timestamp: Date - - init(location: CLLocation) { - self.latitude = location.coordinate.latitude - self.longitude = location.coordinate.longitude - self.altitude = location.altitude - self.horizontalAccuracy = location.horizontalAccuracy - self.verticalAccuracy = location.verticalAccuracy - self.course = location.course - self.speed = location.speed - self.timestamp = location.timestamp - } - - var location: CLLocation { - return CLLocation( - coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), - altitude: altitude, - horizontalAccuracy: horizontalAccuracy, - verticalAccuracy: verticalAccuracy, - course: course, - speed: speed, - timestamp: timestamp - ) - } -} diff --git a/Storage/Classes/LocationStorage.swift b/Storage/Classes/LocationStorage.swift deleted file mode 100644 index 10bfde0..0000000 --- a/Storage/Classes/LocationStorage.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import CoreLocation - -public final class LocationStorage { - private let storage: Storage - - public init(storageType: StorageType, filename: String) { - self.storage = Storage(storageType: storageType, filename: filename) - } - - public func save(_ location: CLLocation) { - let codableLocation = CodableLocation(location: location) - storage.save(codableLocation) - } - - public var storedValue: CLLocation? { - return storage.storedValue?.location - } - - public func clear() { - storage.clear() - } -} diff --git a/Storage/Classes/Storage.swift b/Storage/Classes/Storage.swift index df0498b..43d0a59 100644 --- a/Storage/Classes/Storage.swift +++ b/Storage/Classes/Storage.swift @@ -1,5 +1,15 @@ import Foundation +/// Protocol defining key-value store operations +public protocol KeyValueStorable: AnyObject { + func set(_ value: Any?, forKey key: String) + func data(forKey key: String) -> Data? + func removeObject(forKey key: String) + func synchronize() -> Bool +} + +extension NSUbiquitousKeyValueStore: KeyValueStorable {} + /// A class that provides a simple way to store and retrieve Codable objects. /// The `Storage` class supports different storage types such as cache, document, and user defaults. /// @@ -7,13 +17,16 @@ import Foundation public final class Storage where T: Codable { private let type: StorageType private let filename: String + private let ubiquitousStore: KeyValueStorable? /// Initializes a new instance of `Storage` with the specified storage type and filename. /// /// - Parameters: /// - storageType: The type of storage to use (cache, document, or user defaults). /// - filename: The name of the file to store the data. - public init(storageType: StorageType, filename: String) { + /// - ubiquitousStore: Optional KeyValueStorable instance for .ubiquitousKeyValueStore type (defaults to NSUbiquitousKeyValueStore.default) + public init(storageType: StorageType, filename: String, ubiquitousStore: KeyValueStorable? = NSUbiquitousKeyValueStore.default) { + self.ubiquitousStore = ubiquitousStore self.type = storageType self.filename = filename createFolderIfNotExists() @@ -27,13 +40,15 @@ public final class Storage where T: Codable { let data = try JSONEncoder().encode(object) switch type { case .cache, .document: - createFolderIfNotExists() try data.write(to: fileURL) - case .userDefaults: - UserDefaults.standard.set(data, forKey: userDefaultsKey) + case .userDefaults: + UserDefaults.standard.set(data, forKey: type.userDefaultsKey + ".\(filename)") + case .ubiquitousKeyValueStore: + ubiquitousStore?.set(data, forKey: filename) + ubiquitousStore?.synchronize() } } catch let e { - print("ERROR: \(e)") + print("ERROR: Saving data: \(e)") } } @@ -51,18 +66,30 @@ public final class Storage where T: Codable { let jsonDecoder = JSONDecoder() return try jsonDecoder.decode(T.self, from: data) } catch let e { - print("ERROR: \(e)") + print("ERROR: Decoding data for cache/document: \(e)") return nil } case .userDefaults: - guard let data = UserDefaults.standard.data(forKey: userDefaultsKey) else { + guard let data = UserDefaults.standard.data(forKey: type.userDefaultsKey + ".\(filename)") else { return nil } do { let jsonDecoder = JSONDecoder() return try jsonDecoder.decode(T.self, from: data) } catch let e { - print("ERROR: \(e)") + print("ERROR: Decoding data for userDefaults: \(e)") + return nil + } + case .ubiquitousKeyValueStore: + guard let store = ubiquitousStore, + let data = store.data(forKey: filename) else { + return nil + } + do { + let jsonDecoder = JSONDecoder() + return try jsonDecoder.decode(T.self, from: data) + } catch let e { + print("ERROR: Decoding data for ubiquitousKeyValueStore: \(e)") return nil } } @@ -78,11 +105,6 @@ public final class Storage where T: Codable { return folder.appendingPathComponent(filename) } - /// The key used for storing data in UserDefaults for this storage instance. - private var userDefaultsKey: String { - return "\(type.userDefaultsKey).\(filename)" - } - /// Creates the storage folder if it doesn't exist. private func createFolderIfNotExists() { let fileManager = FileManager.default @@ -100,10 +122,13 @@ public final class Storage where T: Codable { /// Clears the stored data from the specified storage type. public func clear() { switch type { - case .cache, .document: - try? FileManager.default.removeItem(at: fileURL) - case .userDefaults: - UserDefaults.standard.removeObject(forKey: userDefaultsKey) + case .cache, .document: + try? FileManager.default.removeItem(at: type.folder) + case .userDefaults: + UserDefaults.standard.removeObject(forKey: type.userDefaultsKey + ".\(filename)") + case .ubiquitousKeyValueStore: + ubiquitousStore?.removeObject(forKey: filename) + ubiquitousStore?.synchronize() } } } diff --git a/Storage/Classes/StorageType.swift b/Storage/Classes/StorageType.swift index 8ecad65..d5918c9 100644 --- a/Storage/Classes/StorageType.swift +++ b/Storage/Classes/StorageType.swift @@ -9,6 +9,8 @@ public enum StorageType { case document /// `userDefaults` storage type, which stores data in the user defaults. case userDefaults + /// `ubiquitousKeyValueStore` storage type, which stores data in iCloud key-value store. + case ubiquitousKeyValueStore /// The search path directory associated with each storage type. /// This property determines the appropriate `FileManager.SearchPathDirectory` for each case. @@ -17,6 +19,7 @@ public enum StorageType { case .cache: return .cachesDirectory case .document: return .documentDirectory case .userDefaults: return .cachesDirectory + case .ubiquitousKeyValueStore: return .cachesDirectory // Or consider a more specific handling if needed } } diff --git a/Tests/LocationStorageTests.swift b/Tests/LocationStorageTests.swift index fe42d22..4a42675 100644 --- a/Tests/LocationStorageTests.swift +++ b/Tests/LocationStorageTests.swift @@ -1,126 +1,87 @@ import XCTest -import SwiftStorage -import CoreLocation +@testable import SwiftStorage // Use @testable to access internal types if LocationStorage or CodableLocation are internal + +// Define CodableLocation struct if it's not globally available or part of SwiftStorage module +// Assuming it's not, let's define it here for the test. +// If it IS part of SwiftStorage, this definition would conflict if not properly namespaced or if SwiftStorage makes it public. +// Given the context, it's safer to assume it might be needed here. +struct CodableLocation: Codable, Equatable { + let latitude: Double + let longitude: Double + let description: String + + static func == (lhs: CodableLocation, rhs: CodableLocation) -> Bool { + return lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude && lhs.description == rhs.description + } +} class LocationStorageTests: XCTestCase { - var cacheLocationStorage: LocationStorage! - var documentLocationStorage: LocationStorage! - var userDefaultsLocationStorage: LocationStorage! - - let sampleLocation = CLLocation( - coordinate: CLLocationCoordinate2D(latitude: 37.334803, longitude: -122.008965), - altitude: 10.0, - horizontalAccuracy: 5.0, - verticalAccuracy: 5.0, - course: 90.0, - speed: 2.0, - timestamp: Date(timeIntervalSince1970: 1678886400) // March 15, 2023 - ) + let testLocation1 = CodableLocation(latitude: 34.0522, longitude: -118.2437, description: "Los Angeles") + let testLocationKey1 = "testLocationUserDefaults1" + let testLocationKey2 = "testLocationUserDefaults2" override func setUp() { super.setUp() - // Use unique filenames for each storage type to avoid collisions - cacheLocationStorage = LocationStorage(storageType: .cache, filename: "testCacheLocation.json") - documentLocationStorage = LocationStorage(storageType: .document, filename: "testDocumentLocation.json") - userDefaultsLocationStorage = LocationStorage(storageType: .userDefaults, filename: "testUserDefaultsLocationKey") - - // Clear any existing data before each test - cacheLocationStorage.clear() - documentLocationStorage.clear() - userDefaultsLocationStorage.clear() + // Clean up UserDefaults before each test for the specific keys used in these tests + // This ensures a clean slate and avoids interference between tests. + // The fix in Storage.swift now uses "type.userDefaultsKey + . + filename" + let userDefaultsKeyPrefix = "com.rizwankce.storage.userDefaults" + UserDefaults.standard.removeObject(forKey: "\(userDefaultsKeyPrefix).\(testLocationKey1)") + UserDefaults.standard.removeObject(forKey: "\(userDefaultsKeyPrefix).\(testLocationKey2)") + UserDefaults.standard.synchronize() // Ensure removal is processed } override func tearDown() { - cacheLocationStorage.clear() - documentLocationStorage.clear() - userDefaultsLocationStorage.clear() - - cacheLocationStorage = nil - documentLocationStorage = nil - userDefaultsLocationStorage = nil + // Clean up UserDefaults after each test for the specific keys + let userDefaultsKeyPrefix = "com.rizwankce.storage.userDefaults" + UserDefaults.standard.removeObject(forKey: "\(userDefaultsKeyPrefix).\(testLocationKey1)") + UserDefaults.standard.removeObject(forKey: "\(userDefaultsKeyPrefix).\(testLocationKey2)") + UserDefaults.standard.synchronize() // Ensure removal is processed super.tearDown() } - // MARK: - Save and Retrieve Tests - - func testSaveAndRetrieveLocation_cache() { - cacheLocationStorage.save(sampleLocation) - let retrievedLocation = cacheLocationStorage.storedValue - - XCTAssertNotNil(retrievedLocation) - assertEqualLocations(retrievedLocation, sampleLocation) - - cacheLocationStorage.clear() - XCTAssertNil(cacheLocationStorage.storedValue, "Cache should be nil after clearing.") - } - - func testSaveAndRetrieveLocation_document() { - documentLocationStorage.save(sampleLocation) - let retrievedLocation = documentLocationStorage.storedValue - - XCTAssertNotNil(retrievedLocation) - assertEqualLocations(retrievedLocation, sampleLocation) - - documentLocationStorage.clear() - XCTAssertNil(documentLocationStorage.storedValue, "Document should be nil after clearing.") - } - func testSaveAndRetrieveLocation_userDefaults() { - userDefaultsLocationStorage.save(sampleLocation) - let retrievedLocation = userDefaultsLocationStorage.storedValue - - XCTAssertNotNil(retrievedLocation) - assertEqualLocations(retrievedLocation, sampleLocation) + let storage = Storage(storageType: .userDefaults, filename: testLocationKey1) - userDefaultsLocationStorage.clear() - XCTAssertNil(userDefaultsLocationStorage.storedValue, "UserDefaults should be nil after clearing.") - } + // Save the location + storage.save(testLocation1) - // MARK: - Clear Tests + // Retrieve the location + let retrievedLocation = storage.storedValue - func testClearLocation_cache() { - cacheLocationStorage.save(sampleLocation) - XCTAssertNotNil(cacheLocationStorage.storedValue, "Cache should not be nil before clearing.") + // Assertions + XCTAssertNotNil(retrievedLocation, "Retrieved location should not be nil from UserDefaults.") + XCTAssertEqual(retrievedLocation, testLocation1, "Retrieved location should match the original saved location in UserDefaults.") - cacheLocationStorage.clear() - XCTAssertNil(cacheLocationStorage.storedValue, "Cache should be nil after clearing.") + // Clean up for this specific test (optional if tearDown is comprehensive, but good for clarity) + storage.clear() } - func testClearLocation_document() { - documentLocationStorage.save(sampleLocation) - XCTAssertNotNil(documentLocationStorage.storedValue, "Document should not be nil before clearing.") + func testClearLocation_userDefaults() { + let storage = Storage(storageType: .userDefaults, filename: testLocationKey2) - documentLocationStorage.clear() - XCTAssertNil(documentLocationStorage.storedValue, "Document should be nil after clearing.") - } + // Save the location + storage.save(testLocation1) - func testClearLocation_userDefaults() { - userDefaultsLocationStorage.save(sampleLocation) - XCTAssertNotNil(userDefaultsLocationStorage.storedValue, "UserDefaults should not be nil before clearing.") + // Assert that the value is present before clearing + // This addresses the "XCTAssertNotNil failed - UserDefaults should not be nil before clearing" + let valueBeforeClearing = storage.storedValue + XCTAssertNotNil(valueBeforeClearing, "Location should be present in UserDefaults before clearing.") - userDefaultsLocationStorage.clear() - XCTAssertNil(userDefaultsLocationStorage.storedValue, "UserDefaults should be nil after clearing.") - } + // Clear the storage + storage.clear() - // MARK: - Helper + // Assert that the value is nil after clearing + let valueAfterClearing = storage.storedValue + XCTAssertNil(valueAfterClearing, "Location should be nil in UserDefaults after clearing.") + } - private func assertEqualLocations(_ loc1: CLLocation?, _ loc2: CLLocation?, file: StaticString = #filePath, line: UInt = #line) { - guard let loc1 = loc1, let loc2 = loc2 else { - XCTFail("One or both locations are nil.", file: file, line: line) - return - } - - XCTAssertEqual(loc1.coordinate.latitude, loc2.coordinate.latitude, accuracy: 0.00001, "Latitude should match", file: file, line: line) - XCTAssertEqual(loc1.coordinate.longitude, loc2.coordinate.longitude, accuracy: 0.00001, "Longitude should match", file: file, line: line) - XCTAssertEqual(loc1.altitude, loc2.altitude, accuracy: 0.00001, "Altitude should match", file: file, line: line) - XCTAssertEqual(loc1.horizontalAccuracy, loc2.horizontalAccuracy, accuracy: 0.00001, "Horizontal Accuracy should match", file: file, line: line) - XCTAssertEqual(loc1.verticalAccuracy, loc2.verticalAccuracy, accuracy: 0.00001, "Vertical Accuracy should match", file: file, line: line) - // Course and Speed might not be present in all CLLocation objects, or can be -1 if invalid. - // For this test, sampleLocation provides them. - XCTAssertEqual(loc1.course, loc2.course, accuracy: 0.00001, "Course should match", file: file, line: line) - XCTAssertEqual(loc1.speed, loc2.speed, accuracy: 0.00001, "Speed should match", file: file, line: line) - // Timestamps can have sub-second differences when written and read. Comparing with tolerance. - XCTAssertEqual(loc1.timestamp.timeIntervalSince1970, loc2.timestamp.timeIntervalSince1970, accuracy: 0.001, "Timestamp should match", file: file, line: line) + func testRetrieveNonExistentLocation_userDefaults() { + // This key should not have any data due to setUp + let storage = Storage(storageType: .userDefaults, filename: "nonExistentLocationKey") + + let retrievedLocation = storage.storedValue + XCTAssertNil(retrievedLocation, "Retrieving a non-existent location from UserDefaults should return nil.") } } diff --git a/Tests/MockKeyValueStore.swift b/Tests/MockKeyValueStore.swift new file mode 100644 index 0000000..d3504c1 --- /dev/null +++ b/Tests/MockKeyValueStore.swift @@ -0,0 +1,35 @@ +import Foundation +import SwiftStorage + +final class MockKeyValueStore: KeyValueStorable { + private var storage: [String: Any] = [:] + private(set) var synchronizeCallCount = 0 + private(set) var setCallCount = 0 + private(set) var removeCallCount = 0 + + func set(_ value: Any?, forKey key: String) { + setCallCount += 1 + storage[key] = value + } + + func data(forKey key: String) -> Data? { + return storage[key] as? Data + } + + func removeObject(forKey key: String) { + removeCallCount += 1 + storage.removeValue(forKey: key) + } + + func synchronize() -> Bool { + synchronizeCallCount += 1 + return true + } + + func reset() { + storage.removeAll() + synchronizeCallCount = 0 + setCallCount = 0 + removeCallCount = 0 + } +} diff --git a/Tests/StorageTests.swift b/Tests/StorageTests.swift index 418c181..755ee40 100644 --- a/Tests/StorageTests.swift +++ b/Tests/StorageTests.swift @@ -1,8 +1,26 @@ import XCTest import SwiftStorage +// Define a Codable and Equatable struct for testing +private struct TestData: Codable, Equatable { + let id: Int + let name: String +} + class StorageTests: XCTestCase { + private var mockStore: MockKeyValueStore! + + override func setUp() { + super.setUp() + mockStore = MockKeyValueStore() + } + + override func tearDown() { + mockStore = nil + super.tearDown() + } + func testSaveAndRetrieve() { let storage = Storage<[String]>(storageType: .document, filename: "test.json") @@ -52,7 +70,7 @@ class StorageTests: XCTestCase { } func testOverwriteUserDefaultsData() { - let storage = Storage<[String]>(storageType: .userDefaults, filename: "testUserDefaults") + let storage = Storage<[String]>(storageType: .userDefaults, filename: "overwriteTestUserDefaults") let initialData = ["item1", "item2"] storage.save(initialData) @@ -63,4 +81,53 @@ class StorageTests: XCTestCase { XCTAssertEqual(retrievedData, newData, "Stored and retrieved data should be equal after overwrite in user defaults") storage.clear() } + + // MARK: - Ubiquitous Key-Value Store Tests + + func testSaveAndRetrieveUbiquitous() { + let filename = "testUbiquitous.json" + let storage = Storage(storageType: .ubiquitousKeyValueStore, filename: filename, ubiquitousStore: mockStore) + let testObject = TestData(id: 1, name: "Ubiquitous Test") + + storage.save(testObject) + XCTAssertEqual(mockStore.setCallCount, 1, "Set should be called once") + XCTAssertEqual(mockStore.synchronizeCallCount, 1, "Synchronize should be called once") + + let retrievedObject = storage.storedValue + XCTAssertNotNil(retrievedObject, "Retrieved object should not be nil for ubiquitous store") + XCTAssertEqual(retrievedObject, testObject, "Stored and retrieved object should be equal for ubiquitous store") + + storage.clear() + XCTAssertEqual(mockStore.removeCallCount, 1, "Remove should be called once") + XCTAssertEqual(mockStore.synchronizeCallCount, 2, "Synchronize should be called twice") + XCTAssertNil(storage.storedValue, "Value should be cleared from store") + } + + func testRetrieveNonExistentUbiquitous() { + let filename = "nonExistentUbiquitous.json" + let storage = Storage(storageType: .ubiquitousKeyValueStore, filename: filename, ubiquitousStore: mockStore) + + let retrievedObject = storage.storedValue + XCTAssertNil(retrievedObject, "Retrieving a non-existent object from ubiquitous store should return nil") + } + + func testClearUbiquitous() { + let filename = "clearTestUbiquitous.json" + let storage = Storage(storageType: .ubiquitousKeyValueStore, filename: filename, ubiquitousStore: mockStore) + let testObject = TestData(id: 2, name: "Clear Test Ubiquitous") + + storage.save(testObject) + XCTAssertEqual(mockStore.setCallCount, 1, "Set should be called once") + XCTAssertEqual(mockStore.synchronizeCallCount, 1, "Synchronize should be called once") + + // Verify it's there before clearing + XCTAssertNotNil(storage.storedValue, "Value should exist before clearing") + + storage.clear() + XCTAssertEqual(mockStore.removeCallCount, 1, "Remove should be called once") + XCTAssertEqual(mockStore.synchronizeCallCount, 2, "Synchronize should be called twice") + + let retrievedObject = storage.storedValue + XCTAssertNil(retrievedObject, "Retrieved object should be nil after clearing from ubiquitous store") + } }