diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index b70017f..edb9f7c 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -8,17 +8,23 @@ on: jobs: build: - - runs-on: macos-latest - + strategy: + matrix: + os: [macos-14] + swift: ["5.9"] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - #- uses: maxim-lobanov/setup-xcode@v1 - # with: - # xcode-version: '>15.0' + - uses: swift-actions/setup-swift@v1 + name: Set up Swift + with: + swift-version: ${{ matrix.swift }} + - name: Get Swift version + run: swift --version + - uses: actions/checkout@v4 + name: Checkout - name: Build run: swift build -v - - name: Run tests + - name: Test run: swift test -v --enable-code-coverage - name: Upload to Codecov - uses: codecov/codecov-action@v3.1.0 + uses: codecov/codecov-action@v3 diff --git a/Package.swift b/Package.swift index 19d0eb6..e1ec3e4 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.0 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,25 +6,49 @@ import PackageDescription let package = Package( name: "KeyValueStorage", platforms: [ - .iOS(.v9), - .macOS(.v10_10), - .watchOS(.v2), - .tvOS(.v9) + .iOS(.v13), + .macOS(.v10_15), + .watchOS(.v6), + .tvOS(.v13) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "KeyValueStorage", targets: ["KeyValueStorage"]), + .library( + name: "KeyValueStorageWrapper", + targets: ["KeyValueStorageWrapper"]), + .library( + name: "KeyValueStorageSwiftUI", + targets: ["KeyValueStorageSwiftUI"]), + .library( + name: "UnifiedStorage", + targets: ["UnifiedStorage"]), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "UnifiedStorage", + dependencies: [], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ]), .target( name: "KeyValueStorage", dependencies: []), + .target( + name: "KeyValueStorageWrapper", + dependencies: [.target(name: "KeyValueStorage")]), + .target( + name: "KeyValueStorageSwiftUI", + dependencies: [.target(name: "KeyValueStorageWrapper")]), .testTarget( name: "KeyValueStorageTests", - dependencies: ["KeyValueStorage"]), + dependencies: ["KeyValueStorage", "KeyValueStorageWrapper", "KeyValueStorageSwiftUI"]), + .testTarget( + name: "UnifiedStorageTests", + dependencies: ["UnifiedStorage"]), ] ) diff --git a/README.md b/README.md index d57a162..987c924 100644 --- a/README.md +++ b/README.md @@ -1,182 +1,282 @@ -### INFO! -Introducing the next evolution of the framework (`v2`). This version has been thoroughly rewritten from the ground up to support concurrency, a robust architecture that strictly adheres to SOLID principles, enhanced testability, ease of use, and much more. (Check the `unified-storage` branch before the official release.) - - # KeyValueStorage ![Build & Test](https://github.com/narek-sv/KeyValueStorage/actions/workflows/swift.yml/badge.svg) -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/narek-sv/KeyValueStorage/actions/workflows/swift.yml) +[![Coverage](https://img.shields.io/badge/coverage->=90%25-brightgreen)](https://github.com/narek-sv/KeyValueStorage/actions/workflows/swift.yml) [![Swift Package Manager compatible](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-success.svg)](https://github.com/apple/swift-package-manager) [![CocoaPods compatible](https://img.shields.io/cocoapods/v/KeyValueStorageSwift)](https://cocoapods.org/pods/KeyValueStorageSwift) --- -An elegant, fast, thread-safe, multipurpose key-value storage, compatible with all Apple platforms. +Enhance your development with the state-of-the-art key-value storage framework, meticulously designed for speed, safety, and simplicity. Leveraging Swift's advanced error handling and concurrency features, the framework ensures thread-safe interactions, bolstered by a robust, modular, and protocol-oriented architecture. Unique to the solution, types of values are encoded within the keys, enabling compile-time type inference and eliminating the need for unnecessary casting. It is designed with App Groups in mind, facilitating seamless data sharing between your apps and extensions. Experience a testable, easily integrated storage solution that redefines efficiency and ease of use. + --- ## Supported Platforms -| iOS | macOS | watchOS | tvOS | +| | | | | | --- | --- | --- | --- | -| 9.0+ | 10.10+ | 2.0+ | 9.0+ | +| **iOS** | **macOS** | **watchOS** | **tvOS** | +| 13.0+ | 10.15+ | 6.0+ | 13.0+ | + +## Built-in Storage Types + +| | | | | +| --- | --- | --- | --- | +| **In Memory** | **User Defaults** | **Keychain** | **File System** | --- -## Installation +## App Groups -### [Swift Package Manager](https://swift.org/package-manager/) +`KeyValueStorage` also supports working with shared containers, which allows you to share your items among different ***App Extensions*** or ***your other Apps***. To do so, first, you need to configure your app by following the steps described in [this](https://developer.apple.com/documentation/security/keychain_services/keychain_items/sharing_access_to_keychain_items_among_a_collection_of_apps) article. -Swift Package Manager is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler. +By providing corresponding `domain`s to each type of storage, you can enable the sharing of storage spaces. Alternatively, by doing so, you can also keep the containers isolated. -Once you have your Swift package set up, adding KeyValueStorage as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`. +--- +## Usage + +The framework is capable of working with any type that conforms to `Codable` and `Sendable`. +The concept here is that first you need to declare the key. It contains every piece of information about how and where the value is stored. + +First, you need to declare the key. You can use one of the built-in types: + +* `UserDefaultsKey` +* `KeychainKey` +* `InMemoryKey` +* `FileKey` + +or you can define your own ones. [See how to do that](#custom-storages) ```swift -dependencies: [ - .package(url: "https://github.com/narek-sv/KeyValueStorage.git", .upToNextMajor(from: "1.0.1")) -] +import KeyValueStorage + +let key = UserDefaultsKey(key: "myKey") +// or alternatively provide the domain +let otherKey = UserDefaultsKey(key: "myKey", domain: "sharedContainer") ``` -or +As you can see, the key holds all the necessary information about the value: +* The key name - `"myKey"` +* The storage type - `UserDefaults` +* The value type - `String` +* The domain (*optional*) - `"sharedContainer"` -* In Xcode select *File > Add Packages*. -* Enter this project's URL: https://github.com/narek-sv/KeyValueStorage.git -In any file you'd like to use KeyValueStorage in, don't forget to -import the framework with `import KeyValueStorage`. +Now all that is left is to instantiate the storage and use it: -### [CocoaPods](https://cocoapods.org) +```swift +// Instantiate the storage +let storage = UnifiedStorage() -CocoaPods is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate KeyValueStorage into your Xcode project using CocoaPods, specify it in your `Podfile`: +// Saves the item and associates it with the key, +// or overrides the value if there is already such an item +try await storage.save("Alice", forKey: key) -```ruby -pod 'KeyValueStorageSwift' -``` +// Returns the item associated with the key or returns nil if there is no such item +let value = try await storage.fetch(forKey: key) -Then run `pod install`. +// Deletes the item associated with the key or does nothing if there is no such item +try await storage.delete(forKey: key) + +// Sets the item identified by the key to the provided value +try await storage.set("Bob", forKey: key) // save +try await storage.set(nil, forKey: key) // delete + +// Clears only the storage associated with the specified storage and domain +try await storage.clear(storage: InMemoryStorage.self, forDomain: "someDomain") -In any file you'd like to use KeyValueStorage in, don't forget to -import the framework with `import KeyValueStorageSwift`. +// Clears only the storage associated with the specified storage for all domains +try await storage.clear(storage: InMemoryStorage.self) + +// Clears the whole storage content +try await storage.clear() +``` --- -## Usage +## Type Inference -### Main functionality +The framework leverages the full capabilities of ***Swift Generics***, so it can infer the types of values based on the key compile-time, eliminating the need for extra checks or type casting. -First, initialize the storage: ```swift -let storage = KeyValueStorage() +struct MyType: Codable, Sendable { ... } + +let key = UserDefaultsKey(key: "myKey") +let value = try await storage.fetch(forKey: key) // inferred type for value is MyType +try await storage.save(/* accepts only MyType*/, forKey: key) ``` -then declare the key by specifing the key name and the type of the item: +--- +## Custom Storages + +`UnifiedStorage` has 4 built-in storage types: +* `In-memory` - This storage type persists the items only within an app session. +* `User-Defaults` - This storage type persists the items within the app's lifetime. +* `File-System` - This storage saves your key-values as separate files in your file system. +* `Keychain` - This storage type keeps the items in secure storage and persists even after app re-installations. Supports `iCloud` synchronization. +You can also define your own storage, and it will work with it seamlessly with `UnifiedStorage` out of the box. +All you need to do is: +1. Define your own type that conforms to the `KeyValueDataStorage` protocol: ```swift -let key = KeyValueStorageKey(name: "myAge") +class NewStorage: KeyValueDataStorage { ... } ``` - -after which you can save: +2. Define the new key type (optional, for ease of use): ```swift -// Saves the item and associates it with the key or overrides the value if there is already such item. - -let myAge = 21 -storage.save(myAge, forKey: key) +typealias NewStorageKey = UnifiedStorageKey ``` -fetch: -```swift -// Fetches and returns the item associated with the key or returns nil if there is no such item. +That's it. You can use it now as the built-in storages: -let fetchedAge = storage.fetch(forKey: key) +```swift +let key = NewStorageKey(key: customKey) +try await storage.save(UUID(), forKey: key) ``` -delete: -```swift -// Deletes the item associated with the key or does nothing if there is no such item. +***NOTE***! You need to handle the thread safety of your storage on your own. -storage.delete(forKey: key) -``` +--- +## Xcode autocompletion + +To get the advantages of Xcode autocompletion, it is recommended to declare all your keys in the extension of the `UnifiedStorageKey`, like this: -set: ```swift -// Sets the item identified by the key to the provided value. +extension UnifiedStorageKey { + static var key1: UserDefaultsKey { + .init(key: "key1", domain: nil) + } + + static var key2: InMemoryKey { + .init(key: "key2", domain: "sharedContainer") + } + + static var key3: KeychainKey { + .init(key: .init(name: "key3", accessibility: .afterFirstUnlock, isSynchronizable: true), + domain: .init(groupId: "groupId", teamId: "teamId")) + } + + static var key4: FileKey { + .init(key: "key4", domain: "otherContainer") + } +} +``` -let newAge = 24 -storage.set(newAge, forKey: key) // save +then Xcode will suggest all the keys specified in the extension when you put a dot: +Screenshot 2024-03-03 at 13 43 39 -storage.set(nil, forKey: key) // delete -``` +--- +## Keychain + +Use `accessibility` parameter to specify the security level of the keychain storage. +By default the `.whenUnlocked` option is used. It is one of the most restrictive options and provides good data protection. + +You can use `.afterFirstUnlock` if you need your app to access the keychain item while in the background. Note that it is less secure than the `.whenUnlocked` option. + +Here are all the supported accessibility types: +* `afterFirstUnlock` +* `afterFirstUnlockThisDeviceOnly` +* `whenPasscodeSetThisDeviceOnly` +* `whenUnlocked` +* `whenUnlockedThisDeviceOnly` + +Set `synchronizable` property to `true` to enable keychain items synchronization across user's multiple devices. The synchronization will work for users who have the ***Keychain*** enabled in the ***iCloud*** settings on their devices. Deleting a synchronizable item will remove it from all devices. -or clear the whole storage content: ```swift -storage.clear() +let key = KeychainKey(key: .init(name: "key", accessibility: .afterFirstUnlock, isSynchronizable: true), + domain: .init(groupId: "groupId", teamId: "teamId")) ``` -`KeyValueStorage` works with any type that conforms to `Codable` protocol. +--- +## Observation + +The `UnifiedStorage` initializer takes a `factory` parameter that conforms to the `UnifiedStorageFactory` protocol, enabling customized storage instantiation and configuration. This feature is particularly valuable for mocking storage in tests or substituting default implementations with custom ones. -### Storage types +By default, this parameter is set to `DefaultUnifiedStorageFactory`, which omits observation capabilities to avoid excessive class burden. However, supplying an `ObservableUnifiedStorageFactory` instance as the parameter activates observation of all underlying storages for changes. -The KeyValueStorage supports 3 storage types -* `In-memory` (This storage type persists the items only within an app session.) -* `User-Defaults` (This storage type persists the items within the whole app lifetime.) -* `Keychain` (This storage type keeps the items in secure storage and persists even app re-installations. Supports `iCloud` synchronization.) -You specify the storage type when declaring the key: +Combine style publishers: ```swift -let key1 = KeyValueStorageKey(name: "id", storage: .inMemory) -let key2 = KeyValueStorageKey(name: "birthday", storage: .userDefaults) -let key3 = KeyValueStorageKey(name: "password", storage: .keychain()) -``` -If you don't specify a storage type `.userDefaults` will be used. +let key = InMemoryKey(key: "key") +guard let publisher = try await storage.publisher(forKey: key) else { + // The storage is not properly configured + return +} -### Xcode autocompletion +let subscription = publisher.sink { value in + print(value) // String? +} +``` -To get the advantages of the Xcode autocompletion it is recommended to declare all your keys in the extension of the `KeyValueStorageKey` like so: +Concurrency style async streams: ```swift -extension KeyValueStorageKey { - static var key1: KeyValueStorageKey { - .init(name: "id", storage: .inMemory) - } - - static var key2: KeyValueStorageKey { - .init(name: "birthday", storage: .userDefaults) - } - - static var key3: KeyValueStorageKey { - .init(name: "password", storage: .keychain()) - } +guard let stream = try await storage.stream(forKey: key) else { + // The storage is not properly configured + return +} + +for await value in stream { + print(value) // String? } ``` -then Xcode will suggest all the keys specified in the extension when you put a dot: -Screen Shot 2022-08-20 at 18 04 02 +However, it's important to note that `UnifiedStorage` can only observe changes made through its own methods. -### App Groups +--- +## Error handling -`KeyValueStorage` also supports working with shared containers, which allows you to share your items among different **App Extensions** or **your other Apps**. To do so, first, you need to configure your app by following the steps described in [this](https://developer.apple.com/documentation/security/keychain_services/keychain_items/sharing_access_to_keychain_items_among_a_collection_of_apps) article. +Despite the fact that all the methods of the `UnifiedStorage` are throwing, it will never throw an exception if you do all the initial setups correctly. -Then you simply have to initialize your `KeyValueStorage` with the `init(accessGroup:teamID:)` initializer by providing your newly created `accessGroup` identifier and your development `teamID`. That's it; you are ready to use **App Groups**. +--- +## Thread Safety -### Keychain +All built-in types leverage the power of ***Swift Concurrency*** and are thread-safe and protected from race conditions and data racing. However, if you extend the storage with your own ones, it is your responsibility to make them thread-safe. -Use `accessibility` parameter to specify the security level of the keychain storage. -By default the `.whenUnlocked` option is used. It is one of the most restrictive options and provides good data protection. +--- +## Tests + +The whole framework is thoroughly validated with high-quality unit tests. +Additionally, it serves as an excellent demonstration of how to use the framework as intended. + +--- +## Installation + +### [Swift Package Manager](https://swift.org/package-manager/) + +Once you have your Swift package set up, adding KeyValueStorage as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`. ```swift -let key = KeyValueStorageKey(name: "password", storage: .keychain(accessibility: .whenUnlocked)) +dependencies: [ + .package(url: "https://github.com/narek-sv/KeyValueStorage.git", .upToNextMajor(from: "2.0.0")) +] ``` -You can use `.afterFirstUnlock` if you need your app to access the keychain item while in the background. Note that it is less secure than the `.whenUnlocked` option. +or -Here are all the supported accessibility types: -* *afterFirstUnlock* -* *afterFirstUnlockThisDeviceOnly* -* *whenPasscodeSetThisDeviceOnly* -* *whenUnlocked* -* *whenUnlockedThisDeviceOnly* +* In Xcode select *File > Add Packages*. +* Enter the project's URL: https://github.com/narek-sv/KeyValueStorage.git -Set `synchronizable` property to `true` to enable keychain items synchronization across user's multiple devices. The synchronization will work for users who have the **Keychain** enabled in the *iCloud* settings on their devices. Deleting a synchronizable item will remove it from all devices. +In any file you'd like to use the package in, don't forget to +import the framework: ```swift -let key = KeyValueStorageKey(name: "password", storage: .keychain(accessibility: .afterFirstUnlock, isSynchronizable: true)) +import KeyValueStorage ``` + +### [CocoaPods](https://cocoapods.org) + +To integrate KeyValueStorage into your Xcode project using CocoaPods, specify it in your `Podfile`: + +```ruby +pod 'KeyValueStorageSwift' +``` + +Then run `pod install`. + +In any file you'd like to use the package in, don't forget to +import the framework: + +```swift +import KeyValueStorageSwift +``` + --- ## License diff --git a/Sources/KeyValueStorage/KeyValueStorage.swift b/Sources/KeyValueStorage/KeyValueStorage.swift index c66a280..bfbc1b0 100644 --- a/Sources/KeyValueStorage/KeyValueStorage.swift +++ b/Sources/KeyValueStorage/KeyValueStorage.swift @@ -14,10 +14,10 @@ open class KeyValueStorage { private let userDefaults: UserDefaults private let keychain: KeychainHelper private let serialQueue = DispatchQueue(label: "KeyValueStorage.default.queue", qos: .userInitiated) - private var serviceName: String { accessGroup.unwrapped(Self.defaultServiceName) } - private static var defaultServiceName: String = { Bundle.main.bundleIdentifier.unwrapped("defaultSuiteName") }() + private static let defaultServiceName = Bundle.main.bundleIdentifier.unwrapped("KeyValueStorage") private static var inMemoryStorage = [String: [String: Any]]() - + public var serviceName: String { accessGroup.unwrapped(Self.defaultServiceName) } + /// `accessGroup` is used to identify which Access Group all items belongs to. This allows using shared access between different applications. public let accessGroup: String? diff --git a/Sources/KeyValueStorage/KeyValueStoragePropertyWrapper.swift b/Sources/KeyValueStorage/KeyValueStoragePropertyWrapper.swift deleted file mode 100644 index 47674c1..0000000 --- a/Sources/KeyValueStorage/KeyValueStoragePropertyWrapper.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// KeyValueStoragePropertyWrapper.swift -// -// -// Created by Narek Sahakyan on 09.12.23. -// - -import Combine - -fileprivate final class KeyValueStoragePreferences { - static let shared = KeyValueStoragePreferences() - - var publishers = [AnyHashable: Any]() -} - -@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) -@propertyWrapper -open class Storage { - typealias Key = KeyValueStorageKey - - private let preferences: KeyValueStoragePreferences = .shared - private let storage: KeyValueStorage - private let key: Key - - private var publisher: PassthroughSubject { - (preferences.publishers[key] as! PassthroughSubject) - } - - public var wrappedValue: Value? { - get { - storage.fetch(forKey: key) - } - - set { - storage.set(newValue, forKey: key) - publisher.send(newValue) - } - } - - public var projectedValue: AnyPublisher { - publisher.eraseToAnyPublisher() - } - - public init(key: KeyValueStorageKey, storage: KeyValueStorage = .default) { - self.key = key - self.storage = .default - - if preferences.publishers[key] == nil { - preferences.publishers[key] = PassthroughSubject() - } - } -} diff --git a/Sources/KeyValueStorageSwiftUI/KeyValueStoragePropertyWrapper+SwiftUI.swift b/Sources/KeyValueStorageSwiftUI/KeyValueStoragePropertyWrapper+SwiftUI.swift new file mode 100644 index 0000000..ead9d84 --- /dev/null +++ b/Sources/KeyValueStorageSwiftUI/KeyValueStoragePropertyWrapper+SwiftUI.swift @@ -0,0 +1,47 @@ +// +// KeyValueStoragePropertyWrapper+SwiftUI.swift +// +// +// Created by Narek Sahakyan on 10.12.23. +// + +import SwiftUI +import Combine +import KeyValueStorage +import KeyValueStorageWrapper + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +@propertyWrapper +public struct ObservedStorage: DynamicProperty { + @ObservedObject private var updateTrigger = KeyValueStorageUpdateTrigger() + private var underlyingStorage: Storage + + public var wrappedValue: Value? { + get { + underlyingStorage.wrappedValue + } + + nonmutating set { + underlyingStorage.wrappedValue = newValue + } + } + + public var projectedValue: Binding { + .init( + get: { wrappedValue }, + set: { wrappedValue = $0 } + ) + } + + public init(key: KeyValueStorageKey, storage: KeyValueStorage = .default) { + self.underlyingStorage = .init(key: key, storage: storage) + self.updateTrigger.subscribtion = underlyingStorage.publisher.sink { [weak updateTrigger] _ in + updateTrigger?.value.toggle() + } + } + + private final class KeyValueStorageUpdateTrigger: ObservableObject { + var subscribtion: AnyCancellable? + @Published var value = false + } +} diff --git a/Sources/KeyValueStorageWrapper/KeyValueStoragePropertyWrapper.swift b/Sources/KeyValueStorageWrapper/KeyValueStoragePropertyWrapper.swift new file mode 100644 index 0000000..9d0f02b --- /dev/null +++ b/Sources/KeyValueStorageWrapper/KeyValueStoragePropertyWrapper.swift @@ -0,0 +1,60 @@ +// +// KeyValueStoragePropertyWrapper.swift +// +// +// Created by Narek Sahakyan on 09.12.23. +// + +import Combine +import KeyValueStorage + +fileprivate final class KeyValueStoragePreferences { + static let shared = KeyValueStoragePreferences() + + var publishers = [AnyHashable: Any]() +} + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +@propertyWrapper +public struct Storage { + typealias Key = KeyValueStorageKey + + private let preferences = KeyValueStoragePreferences.shared + private let publisherKey: KeyValueStoragePublisherKey + private let storage: KeyValueStorage + private let key: Key + + private var _publisher: PassthroughSubject { + (preferences.publishers[publisherKey] as! PassthroughSubject) + } + + public var wrappedValue: Value? { + get { + storage.fetch(forKey: key) + } + + nonmutating set { + storage.set(newValue, forKey: key) + _publisher.send(newValue) + } + } + + public var publisher: AnyPublisher { + _publisher.eraseToAnyPublisher() + } + + public init(key: KeyValueStorageKey, storage: KeyValueStorage = .default) { + self.key = key + self.storage = storage + self.publisherKey = .init(serviceName: storage.serviceName, key: key) + + if preferences.publishers[publisherKey] == nil { + preferences.publishers[publisherKey] = PassthroughSubject() + } + } + + private struct KeyValueStoragePublisherKey: Hashable { + let serviceName: String + let key: Key + } +} diff --git a/Sources/UnifiedStorage/Coders/DataCoder.swift b/Sources/UnifiedStorage/Coders/DataCoder.swift new file mode 100644 index 0000000..266a017 --- /dev/null +++ b/Sources/UnifiedStorage/Coders/DataCoder.swift @@ -0,0 +1,13 @@ +// +// DataCoder.swift +// +// +// Created by Narek Sahakyan on 31.12.23. +// + +import Foundation + +public protocol DataCoder: Sendable { + func encode(_ value: Value) async throws -> Data + func decode(_ data: Data) async throws -> Value +} diff --git a/Sources/UnifiedStorage/Coders/JSONDataCoder.swift b/Sources/UnifiedStorage/Coders/JSONDataCoder.swift new file mode 100644 index 0000000..561978d --- /dev/null +++ b/Sources/UnifiedStorage/Coders/JSONDataCoder.swift @@ -0,0 +1,26 @@ +// +// JSONDataCoder.swift +// +// +// Created by Narek Sahakyan on 31.12.23. +// + +import Foundation + +public actor JSONDataCoder: DataCoder { + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + public init(decoder: JSONDecoder = .init(), encoder: JSONEncoder = .init()) { + self.decoder = decoder + self.encoder = encoder + } + + public func encode(_ value: Value) throws -> Data { + try encoder.encode(value) + } + + public func decode(_ data: Data) throws -> Value { + try decoder.decode(Value.self, from: data) + } +} diff --git a/Sources/UnifiedStorage/Coders/XMLDataCoder.swift b/Sources/UnifiedStorage/Coders/XMLDataCoder.swift new file mode 100644 index 0000000..773c2b8 --- /dev/null +++ b/Sources/UnifiedStorage/Coders/XMLDataCoder.swift @@ -0,0 +1,26 @@ +// +// XMLDataCoder.swift +// +// +// Created by Narek Sahakyan on 31.12.23. +// + +import Foundation + +public actor XMLDataCoder: DataCoder { + private let decoder: PropertyListDecoder + private let encoder: PropertyListEncoder + + public init(decoder: PropertyListDecoder = .init(), encoder: PropertyListEncoder = .init()) { + self.decoder = decoder + self.encoder = encoder + } + + public func encode(_ value: Value) throws -> Data { + try encoder.encode(value) + } + + public func decode(_ data: Data) throws -> Value { + try decoder.decode(Value.self, from: data) + } +} diff --git a/Sources/UnifiedStorage/Helpers/KeychainHelper.swift b/Sources/UnifiedStorage/Helpers/KeychainHelper.swift new file mode 100644 index 0000000..fd7aba0 --- /dev/null +++ b/Sources/UnifiedStorage/Helpers/KeychainHelper.swift @@ -0,0 +1,153 @@ +// +// KeychainHelper.swift +// +// +// Created by Narek Sahakyan on 7/27/22. +// + +import Foundation +import Security + +enum KeychainHelperError: Error { + case status(OSStatus) +} + +/// A wrapper class which allows to use Keychain it in a similar manner to User Defaults. +open class KeychainHelper: @unchecked Sendable { + + /// `serviceName` is used to uniquely identify this keychain accessor. + let serviceName: String + + /// `accessGroup` is used to identify which Keychain Access Group this entry belongs to. This allows you to use shared keychain access between different applications. + let accessGroup: String? + + init(serviceName: String, accessGroup: String? = nil) { + self.serviceName = serviceName + self.accessGroup = accessGroup + } + + func get(forKey key: String, + withAccessibility accessibility: KeychainAccessibility? = nil, + isSynchronizable: Bool = false) throws -> Data? { + var keychainQueryDictionary = query(forKey: key, withAccessibility: accessibility, isSynchronizable: isSynchronizable) + keychainQueryDictionary[KeychainHelper.matchLimit] = kSecMatchLimitOne + keychainQueryDictionary[KeychainHelper.returnData] = kCFBooleanTrue + + // Search + var result: AnyObject? + let status = SecItemCopyMatching(keychainQueryDictionary as CFDictionary, &result) + + if status != errSecSuccess { + throw KeychainHelperError.status(status) + } + + return result as? Data + } + + func set(_ value: Data, + forKey key: String, + withAccessibility accessibility: KeychainAccessibility? = nil, + isSynchronizable: Bool = false) throws { + var keychainQueryDictionary = query(forKey: key, withAccessibility: accessibility, isSynchronizable: isSynchronizable) + keychainQueryDictionary[KeychainHelper.valueData] = value + keychainQueryDictionary[KeychainHelper.attrAccessible] = accessibility?.key ?? KeychainAccessibility.whenUnlocked.key + + let status = SecItemAdd(keychainQueryDictionary as CFDictionary, nil) + if status == errSecDuplicateItem { + try update(value, forKey: key, withAccessibility: accessibility, isSynchronizable: isSynchronizable) + } else if status != errSecSuccess { + throw KeychainHelperError.status(status) + } + } + + func remove(forKey key: String, + withAccessibility accessibility: KeychainAccessibility? = nil, + isSynchronizable: Bool = false) throws { + let keychainQueryDictionary = query(forKey: key, withAccessibility: accessibility, isSynchronizable: isSynchronizable) + + let status = SecItemDelete(keychainQueryDictionary as CFDictionary) + if status != errSecSuccess { + throw KeychainHelperError.status(status) + } + } + + func removeAll() throws { + var keychainQueryDictionary: [String: Any] = [KeychainHelper.class: kSecClassGenericPassword] + keychainQueryDictionary[KeychainHelper.attrService] = serviceName + keychainQueryDictionary[KeychainHelper.attrAccessGroup] = accessGroup + + let status = SecItemDelete(keychainQueryDictionary as CFDictionary) + if status != errSecSuccess { + throw KeychainHelperError.status(status) + } + } + + // MARK: - Helpers + + private func update(_ value: Data, + forKey key: String, + withAccessibility accessibility: KeychainAccessibility? = nil, + isSynchronizable: Bool = false) throws { + let updateDictionary = [KeychainHelper.valueData: value] + var keychainQueryDictionary = query(forKey: key, withAccessibility: accessibility, isSynchronizable: isSynchronizable) + keychainQueryDictionary[KeychainHelper.attrAccessible] = accessibility?.key + + let status = SecItemUpdate(keychainQueryDictionary as CFDictionary, updateDictionary as CFDictionary) + if status != errSecSuccess { + throw KeychainHelperError.status(status) + } + } + + private func query(forKey key: String, + withAccessibility accessibility: KeychainAccessibility? = nil, + isSynchronizable: Bool = false) -> [String: Any] { + var keychainQueryDictionary: [String: Any] = [KeychainHelper.class: kSecClassGenericPassword] + let encodedIdentifier = key.data(using: .utf8) + + keychainQueryDictionary[KeychainHelper.attrService] = serviceName + keychainQueryDictionary[KeychainHelper.attrAccessible] = accessibility?.key + keychainQueryDictionary[KeychainHelper.attrAccessGroup] = accessGroup + keychainQueryDictionary[KeychainHelper.attrGeneric] = encodedIdentifier + keychainQueryDictionary[KeychainHelper.attrAccount] = encodedIdentifier + keychainQueryDictionary[KeychainHelper.attrSynchronizable] = isSynchronizable ? kCFBooleanTrue : kCFBooleanFalse + return keychainQueryDictionary + } +} + +// MARK: - Keys + +extension KeychainHelper { + private static let `class` = kSecClass as String + private static let matchLimit = kSecMatchLimit as String + private static let returnData = kSecReturnData as String + private static let valueData = kSecValueData as String + private static let attrAccessible = kSecAttrAccessible as String + private static let attrService = kSecAttrService as String + private static let attrGeneric = kSecAttrGeneric as String + private static let attrAccount = kSecAttrAccount as String + private static let attrAccessGroup = kSecAttrAccessGroup as String + private static let attrSynchronizable = kSecAttrSynchronizable as String + private static let returnAttributes = kSecReturnAttributes as String +} + +// MARK: - Accessibility + +public enum KeychainAccessibility: Sendable { + case afterFirstUnlock + case afterFirstUnlockThisDeviceOnly + case whenPasscodeSetThisDeviceOnly + case whenUnlocked + case whenUnlockedThisDeviceOnly +// case always (deprecated) +// case alwaysThisDeviceOnly (deprecated) + + var key: String { + switch self { + case .afterFirstUnlock: return kSecAttrAccessibleAfterFirstUnlock as String + case .afterFirstUnlockThisDeviceOnly: return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String + case .whenPasscodeSetThisDeviceOnly: return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly as String + case .whenUnlocked: return kSecAttrAccessibleWhenUnlocked as String + case .whenUnlockedThisDeviceOnly: return kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String + } + } +} diff --git a/Sources/UnifiedStorage/Helpers/SendableConformances.swift b/Sources/UnifiedStorage/Helpers/SendableConformances.swift new file mode 100644 index 0000000..fdd2d17 --- /dev/null +++ b/Sources/UnifiedStorage/Helpers/SendableConformances.swift @@ -0,0 +1,15 @@ +// +// SendableConformances.swift +// +// +// Created by Narek Sahakyan on 31.12.23. +// + +import Foundation +import Combine + +extension UserDefaults: @unchecked Sendable { } +extension AnyPublisher: @unchecked Sendable { } + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +extension AsyncPublisher: @unchecked Sendable { } diff --git a/Sources/UnifiedStorage/Helpers/Typealiases.swift b/Sources/UnifiedStorage/Helpers/Typealiases.swift new file mode 100644 index 0000000..861b381 --- /dev/null +++ b/Sources/UnifiedStorage/Helpers/Typealiases.swift @@ -0,0 +1,15 @@ +// +// Typealiases.swift +// +// +// Created by Narek Sahakyan on 29.12.23. +// + +import Foundation + +public typealias CodingValue = Codable & Sendable +public typealias UserDefaultsKey = UnifiedStorageKey +public typealias KeychainKey = UnifiedStorageKey +public typealias InMemoryKey = UnifiedStorageKey +public typealias FileKey = UnifiedStorageKey + diff --git a/Sources/UnifiedStorage/Storages/FileStorage.swift b/Sources/UnifiedStorage/Storages/FileStorage.swift new file mode 100644 index 0000000..cc26274 --- /dev/null +++ b/Sources/UnifiedStorage/Storages/FileStorage.swift @@ -0,0 +1,160 @@ +// +// FileStorage.swift +// +// +// Created by Narek Sahakyan on 17.12.23. +// + +import Foundation + +// MARK: - Data Storage + +@FileActor +open class FileStorage: KeyValueDataStorage, @unchecked Sendable { + + // MARK: Properties + + private let fileManager: FileManager + private let root: URL + public let domain: Domain? + + // MARK: Initializers + + public required init() throws { + let fileManager = FileManager.default + guard let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw Error.failedToFindDocumentsDirectory + } + + self.root = url.appendingPathComponent(Self.defaultGroup, isDirectory: true) + self.domain = nil + self.fileManager = fileManager + } + + public required init(domain: Domain) throws { + let fileManager = FileManager.default + guard let url = fileManager.containerURL(forSecurityApplicationGroupIdentifier: domain) else { + throw Error.failedToInitSharedDirectory + } + + self.root = url.appendingPathComponent(Self.defaultGroup, isDirectory: true) + self.domain = domain + self.fileManager = fileManager + } + + public init(fileManager: FileManager, root: URL) { + self.fileManager = fileManager + self.root = root + self.domain = nil + } + + // MARK: Main Functionality + + public func fetch(forKey key: Key) throws -> Data? { + fileManager.contents(atPath: directory(for: key).path) + } + + public func save(_ value: Data, forKey key: Key) throws { + try execute { + let directory = directory(for: key) + let directoryPath = directory.path + + try createDirectoryIfDoesntExist(path: root.path) + try deleteFileIfExists(path: directoryPath) + + if !fileManager.createFile(atPath: directoryPath, contents: value) { + throw Error.failedToSave + } + } + } + + public func delete(forKey key: Key) throws { + try execute { + try deleteFileIfExists(path: directory(for: key).path) + } + } + + public func set(_ value: Data?, forKey key: Key) throws { + if let value = value { + try save(value, forKey: key) + } else { + try delete(forKey: key) + } + } + + public func clear() throws { + try execute { + if let fileNames = try? fileManager.contentsOfDirectory(atPath: root.path) { + for fileName in fileNames { + let path = root.appendingPathComponent(fileName).path + try deleteFileIfExists(path: path) + } + } + } + } + + // MARK: Helpers + + private func deleteFileIfExists(path: String) throws { + do { + try fileManager.removeItem(atPath: path) + } catch CocoaError.fileNoSuchFile { + // ok + } catch { + throw error + } + } + + private func createDirectoryIfDoesntExist(path: String) throws { + do { + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true) + } catch CocoaError.fileWriteFileExists { + // ok + } catch { + throw error + } + } + + private func directory(for key: Key) -> URL { + root.appendingPathComponent(key) + } + + private func convert(error: Swift.Error) -> Error { + if let error = error as? FileStorage.Error { + return error + } + + return .other(error) + } + + @discardableResult + private func execute(_ block: () throws -> T) rethrows -> T { + do { + return try block() + } catch { + throw convert(error: error) + } + } +} + +// MARK: - Associated Types + +public extension FileStorage { + typealias Key = String + typealias Domain = String + + enum Error: KeyValueDataStorageError { + case failedToSave + case failedToInitSharedDirectory + case failedToFindDocumentsDirectory + case other(Swift.Error) + } +} + +// MARK: - Global Actors + +@globalActor +public final class FileActor { + public actor Actor { } + public static let shared = Actor() +} diff --git a/Sources/UnifiedStorage/Storages/InMemoryStorage.swift b/Sources/UnifiedStorage/Storages/InMemoryStorage.swift new file mode 100644 index 0000000..3a1703a --- /dev/null +++ b/Sources/UnifiedStorage/Storages/InMemoryStorage.swift @@ -0,0 +1,78 @@ +// +// InMemoryStorage.swift +// +// +// Created by Narek Sahakyan on 11.12.23. +// + +import Foundation + +// MARK: - Data Storage + +@InMemoryActor +open class InMemoryStorage: KeyValueDataStorage, @unchecked Sendable { + + // MARK: Properties + + internal static var container = [Domain?: [Key: Data]]() // not thread safe, for internal use only + public let domain: Domain? + + // MARK: Initializers + + public required init() { + self.domain = nil + } + + public required init(domain: Domain) { + self.domain = domain + } + + // MARK: Main Functionality + + public func fetch(forKey key: Key) -> Data? { + Self.container[domain]?[key] + } + + public func save(_ value: Data, forKey key: Key) { + if Self.container[domain] == nil { + Self.container[domain] = [:] + } + + Self.container[domain]?[key] = value + } + + public func set(_ value: Data?, forKey key: Key) { + if let value = value { + save(value, forKey: key) + } else { + delete(forKey: key) + } + } + + public func delete(forKey key: Key) { + Self.container[domain]?[key] = nil + } + + public func clear() { + Self.container[domain] = [:] + } +} + +// MARK: - Associated Types + +public extension InMemoryStorage { + typealias Key = String + typealias Domain = String + + enum Error: KeyValueDataStorageError { + case unknown + } +} + +// MARK: - Global Actors + +@globalActor +public final class InMemoryActor { + public actor Actor { } + public static let shared = Actor() +} diff --git a/Sources/UnifiedStorage/Storages/KeychainStorage.swift b/Sources/UnifiedStorage/Storages/KeychainStorage.swift new file mode 100644 index 0000000..e1029e9 --- /dev/null +++ b/Sources/UnifiedStorage/Storages/KeychainStorage.swift @@ -0,0 +1,127 @@ +// +// KeychainStorage.swift +// +// +// Created by Narek Sahakyan on 11.12.23. +// + +import Foundation + +// MARK: - Data Storage + +@KeychainActor +open class KeychainStorage: KeyValueDataStorage, @unchecked Sendable { + + // MARK: Properties + + private let keychain: KeychainHelper + public let domain: Domain? + + // MARK: Initializers + + public required init() { + self.domain = nil + self.keychain = KeychainHelper(serviceName: Self.defaultGroup) + } + + public required init(domain: Domain) { + self.domain = domain + self.keychain = KeychainHelper(serviceName: Self.defaultGroup, accessGroup: domain.accessGroup) + } + + public init(keychain: KeychainHelper) { + self.keychain = keychain + self.domain = nil + } + + // MARK: Main Functionality + + public func fetch(forKey key: Key) throws -> Data? { + try execute { + try keychain.get(forKey: key.name, withAccessibility: key.accessibility, isSynchronizable: key.isSynchronizable) + } + } + + public func save(_ value: Data, forKey key: Key) throws { + try execute { + try keychain.set(value, forKey: key.name, withAccessibility: key.accessibility, isSynchronizable: key.isSynchronizable) + } + } + + public func delete(forKey key: Key) throws { + try execute { + try keychain.remove(forKey: key.name, withAccessibility: key.accessibility, isSynchronizable: key.isSynchronizable) + } + } + + public func set(_ value: Data?, forKey key: Key) throws { + if let value = value { + try save(value, forKey: key) + } else { + try delete(forKey: key) + } + } + + public func clear() throws { + try execute { + try keychain.removeAll() + } + } + + // MARK: Helpers + + private func convert(error: Swift.Error) -> Error { + if case let .status(status) = error as? KeychainHelperError { + return .os(status) + } + + return .other(error) + } + + @discardableResult + private func execute(_ block: () throws -> T) rethrows -> T { + do { + return try block() + } catch { + throw convert(error: error) + } + } +} + +// MARK: - Associated Types + +public extension KeychainStorage { + struct Key: KeyValueDataStorageKey { + public let name: String + public let accessibility: KeychainAccessibility? + public let isSynchronizable: Bool + + public init(name: String, accessibility: KeychainAccessibility? = nil, isSynchronizable: Bool = false) { + self.name = name + self.accessibility = accessibility + self.isSynchronizable = isSynchronizable + } + } + + struct Domain: KeyValueDataStorageDomain { + public let groupId: String + public let teamId: String + + public var accessGroup: String { + teamId + "." + groupId + } + } + + enum Error: KeyValueDataStorageError { + case os(OSStatus) + case other(Swift.Error) + } +} + +// MARK: - Global Actors + +@globalActor +public final class KeychainActor { + public actor Actor { } + public static let shared = Actor() +} diff --git a/Sources/UnifiedStorage/Storages/Layers/KeyValueCodingStorage.swift b/Sources/UnifiedStorage/Storages/Layers/KeyValueCodingStorage.swift new file mode 100644 index 0000000..c424b2d --- /dev/null +++ b/Sources/UnifiedStorage/Storages/Layers/KeyValueCodingStorage.swift @@ -0,0 +1,101 @@ +// +// KeyValueCodingStorage.swift +// +// +// Created by Narek Sahakyan on 11.12.23. +// + +import Foundation + +// MARK: - Coding Storage Key + +public struct KeyValueCodingStorageKey: Sendable { + public let key: Storage.Key + public let codingType: Value.Type + + public init(key: Storage.Key) { + self.key = key + self.codingType = Value.self + } + + internal init(key: Storage.Key, codingType: Value.Type) { + self.key = key + self.codingType = codingType + } +} + +extension KeyValueCodingStorageKey: Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.key == rhs.key && + lhs.codingType == rhs.codingType + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(key) + hasher.combine(String(describing: codingType)) + } +} + +// MARK: - Coding Storage + +open class KeyValueCodingStorage: @unchecked Sendable, Clearing { + + // MARK: Properties + + private let coder: DataCoder + private let storage: Storage + + public var domain: Storage.Domain? { + storage.domain + } + + // MARK: Initializers + + public init(storage: Storage, coder: DataCoder = JSONDataCoder()) { + self.coder = coder + self.storage = storage + } + + // MARK: Main Functionality + + public func fetch(forKey key: KeyValueCodingStorageKey) async throws -> Value? { + if let data = try await storage.fetch(forKey: key.key) { + return try await coder.decode(data) + } + + return nil + } + + public func save(_ value: Value, forKey key: KeyValueCodingStorageKey) async throws { + let encoded = try await coder.encode(value) + try await storage.save(encoded, forKey: key.key) + } + + public func set(_ value: Value?, forKey key: KeyValueCodingStorageKey) async throws { + if let value { + try await save(value, forKey: key) + } else { + try await delete(forKey: key) + } + } + + public func delete(forKey key: KeyValueCodingStorageKey) async throws { + try await storage.delete(forKey: key.key) + } + + public func clear() async throws { + try await storage.clear() + } +} + +protocol Clearing { + func clear() async throws +} + +// MARK: - Global Actors + +@globalActor +public final class ObservableCodingStorageActor { + public actor Actor { } + public static let shared = Actor() +} diff --git a/Sources/UnifiedStorage/Storages/Layers/KeyValueDataStorage.swift b/Sources/UnifiedStorage/Storages/Layers/KeyValueDataStorage.swift new file mode 100644 index 0000000..ae671af --- /dev/null +++ b/Sources/UnifiedStorage/Storages/Layers/KeyValueDataStorage.swift @@ -0,0 +1,43 @@ +// +// KeyValueDataStorage.swift +// +// +// Created by Narek Sahakyan on 11.12.23. +// + +import Foundation + +// MARK: - Data Storage Protocol + +public protocol KeyValueDataStorage: Sendable { + associatedtype Key: KeyValueDataStorageKey + associatedtype Domain: KeyValueDataStorageDomain + associatedtype Error: KeyValueDataStorageError + + static var defaultGroup: String { get } + + var domain: Domain? { get } + + init() async throws + init(domain: Domain) async throws + + func fetch(forKey key: Key) async throws -> Data? + func save(_ value: Data, forKey key: Key) async throws + func set(_ value: Data?, forKey key: Key) async throws + func delete(forKey key: Key) async throws + func clear() async throws +} + +// MARK: - Default Implementations + +public extension KeyValueDataStorage { + static var defaultGroup: String { + Bundle.main.bundleIdentifier ?? "KeyValueDataStorage" + } +} + +// MARK: - Associated Type Requirements + +public typealias KeyValueDataStorageKey = Hashable & Sendable +public typealias KeyValueDataStorageDomain = Hashable & Sendable +public typealias KeyValueDataStorageError = Error & Sendable diff --git a/Sources/UnifiedStorage/Storages/Layers/KeyValueObservableStorage.swift b/Sources/UnifiedStorage/Storages/Layers/KeyValueObservableStorage.swift new file mode 100644 index 0000000..6aff8f3 --- /dev/null +++ b/Sources/UnifiedStorage/Storages/Layers/KeyValueObservableStorage.swift @@ -0,0 +1,102 @@ +// +// KeyValueObservableStorage.swift +// +// +// Created by Narek Sahakyan on 30.12.23. +// + +import Combine + +@ObservableCodingStorageActor +private final class KeyValueObservations { + fileprivate static var observations = [AnyHashable?: [AnyHashable: Any]]() +} + +@ObservableCodingStorageActor +open class KeyValueObservableStorage: KeyValueCodingStorage, @unchecked Sendable { + + // MARK: Observations + + public func stream(forKey key: KeyValueCodingStorageKey) -> AsyncStream { + return AsyncStream(bufferingPolicy: .unbounded) { continuation in + let publisher: AnyPublisher = publisher(forKey: key) + let subscription = publisher.sink { + continuation.yield($0) + } + + continuation.onTermination = { _ in + subscription.cancel() + } + } + } + + public func publisher(forKey key: KeyValueCodingStorageKey) -> AnyPublisher { + let mapPublisher = { (publisher: PassthroughSubject) -> AnyPublisher in + publisher + .map { + if let value = $0.value { + return value as? Value + } + + return nil + } + .eraseToAnyPublisher() + } + + if let observation = KeyValueObservations.observations[domain]?[key], + let publisher = observation as? PassthroughSubject { + return mapPublisher(publisher) + } + + if KeyValueObservations.observations[domain] == nil { + KeyValueObservations.observations[domain] = [:] + } + + let publisher = PassthroughSubject() + KeyValueObservations.observations[domain]?[key] = publisher + return mapPublisher(publisher) + } + +/* AsyncPublisher emits most of the value changes*/ +// @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +// public func asyncPublisher(forKey key: KeyValueCodingStorageKey) +// async -> AsyncPublisher> { +// AsyncPublisher(publisher(forKey: key)) +// } + + // MARK: Main Functionality + + public override func save(_ value: Value, forKey key: KeyValueCodingStorageKey) async throws { + try await super.save(value, forKey: key) + publisher(for: key)?.send(.init(value: value)) + } + + public override func delete(forKey key: KeyValueCodingStorageKey) async throws { + try await super.delete(forKey: key) + publisher(for: key)?.send(.init()) + } + + public override func clear() async throws { + try await super.clear() + + for observation in (KeyValueObservations.observations[domain] ?? [:]).values { + if let publisher = observation as? PassthroughSubject { + publisher.send(.init()) + } + } + } + + // MARK: Helpers + + private func publisher(for key: AnyHashable) -> PassthroughSubject? { + KeyValueObservations.observations[domain]?[key] as? PassthroughSubject + } + + // MARK: Inner Hidden Types + + private struct Container { + var value: Any? + } +} + +extension AnyCancellable: @unchecked Sendable { } diff --git a/Sources/UnifiedStorage/Storages/UserDefaultsStorage.swift b/Sources/UnifiedStorage/Storages/UserDefaultsStorage.swift new file mode 100644 index 0000000..8f0db13 --- /dev/null +++ b/Sources/UnifiedStorage/Storages/UserDefaultsStorage.swift @@ -0,0 +1,85 @@ +// +// UserDefaultsStorage.swift +// +// +// Created by Narek Sahakyan on 11.12.23. +// + +import Foundation + +// MARK: - Data Storage + +@UserDefaultsActor +open class UserDefaultsStorage: KeyValueDataStorage, @unchecked Sendable { + + // MARK: Properties + + private let userDefaults: UserDefaults + public let domain: Domain? + + // MARK: Initializers + + public required init() { + self.domain = nil + self.userDefaults = .standard + } + + public required init(domain: Domain) throws { + guard let defaults = UserDefaults(suiteName: domain) else { + throw Error.failedToInitSharedDefaults + } + + self.domain = domain + self.userDefaults = defaults + } + + public init(userDefaults: UserDefaults) { + self.userDefaults = userDefaults + self.domain = nil + } + + // MARK: Main Functionality + + public func fetch(forKey key: Key) -> Data? { + userDefaults.data(forKey: key) + } + + public func save(_ value: Data, forKey key: Key) { + userDefaults.set(value, forKey: key) + } + + public func delete(forKey key: Key) { + userDefaults.removeObject(forKey: key) + } + + public func set(_ value: Data?, forKey key: Key) { + if let value = value { + save(value, forKey: key) + } else { + delete(forKey: key) + } + } + + public func clear() { + userDefaults.removePersistentDomain(forName: domain ?? Self.defaultGroup) + } +} + +// MARK: - Associated Types + +public extension UserDefaultsStorage { + typealias Key = String + typealias Domain = String + + enum Error: KeyValueDataStorageError { + case failedToInitSharedDefaults + } +} + +// MARK: - Global Actors + +@globalActor +public final class UserDefaultsActor { + public actor Actor { } + public static let shared = Actor() +} diff --git a/Sources/UnifiedStorage/UnifiedStorage.swift b/Sources/UnifiedStorage/UnifiedStorage.swift new file mode 100644 index 0000000..d5f150c --- /dev/null +++ b/Sources/UnifiedStorage/UnifiedStorage.swift @@ -0,0 +1,188 @@ +// +// UnifiedStorage.swift +// +// +// Created by Narek Sahakyan on 11.12.23. +// + +import Combine + +// MARK: - Unified Storage Key + +public struct UnifiedStorageKey: Sendable { + public let key: Storage.Key + public let domain: Storage.Domain? + public let codingType: Value.Type + public let storageType: Storage.Type + + public init(key: Storage.Key, domain: Storage.Domain? = nil) { + self.key = key + self.domain = domain + self.codingType = Value.self + self.storageType = Storage.self + } +} + +extension UnifiedStorageKey: Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.key == rhs.key && + lhs.domain == rhs.domain && + lhs.codingType == rhs.codingType && + lhs.storageType == rhs.storageType + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(key) + hasher.combine(domain) + hasher.combine(String(describing: codingType)) + hasher.combine(String(describing: storageType)) + } +} + +// MARK: - Unified Storage Factory + +public protocol UnifiedStorageFactory { + func dataStorage(for domain: Storage.Domain?) async throws -> Storage + func codingStorage(for storage: Storage) throws -> KeyValueCodingStorage +} + +open class DefaultUnifiedStorageFactory: UnifiedStorageFactory { + public func dataStorage(for domain: Storage.Domain?) async throws -> Storage { + if let domain { + return try await Storage(domain: domain) + } + + return try await Storage() + } + + public func codingStorage(for storage: Storage) throws -> KeyValueCodingStorage { + KeyValueCodingStorage(storage: storage) + } +} + +public final class ObservableUnifiedStorageFactory: DefaultUnifiedStorageFactory { + public override func codingStorage(for storage: Storage) throws -> KeyValueCodingStorage { + KeyValueObservableStorage(storage: storage) + } +} + +// MARK: - Unified Storage + +public actor UnifiedStorage { + + // MARK: Type Aliases + + public typealias Key = UnifiedStorageKey + + // MARK: Properties + + private var storages = [AnyHashable?: Any]() + private let factory: UnifiedStorageFactory + + // MARK: Initializers + + public init(factory: UnifiedStorageFactory) { + self.factory = factory + } + + public init() { + self.init(factory: DefaultUnifiedStorageFactory()) + } + + // MARK: Main Functionality + + public func fetch(forKey key: Key) async throws -> Value? { + let storage: KeyValueCodingStorage = try await storage(for: key.domain) + return try await storage.fetch(forKey: .init(key: key.key)) + } + + public func save(_ value: Value, forKey key: Key) async throws { + let storage: KeyValueCodingStorage = try await storage(for: key.domain) + try await storage.save(value, forKey: .init(key: key.key)) + } + + public func set(_ value: Value?, forKey key: Key) async throws { + let storage: KeyValueCodingStorage = try await storage(for: key.domain) + try await storage.set(value, forKey: .init(key: key.key)) + } + + public func delete(forKey key: Key) async throws { + let storage: KeyValueCodingStorage = try await storage(for: key.domain) + try await storage.delete(forKey: .init(key: key.key, codingType: key.codingType)) + } + + public func clear(storage: Storage.Type, forDomain domain: Storage.Domain?) async throws { + let storage: KeyValueCodingStorage = try await self.storage(for: domain) + try await storage.clear() + } + + public func clear(storage: Storage.Type) async throws { + for storage in storages.values { + if let casted = storage as? KeyValueCodingStorage { + try await casted.clear() + } + } + } + + public func clear() async throws { + for storage in storages.values { + if let casted = storage as? Clearing { + try await casted.clear() + } + } + } + + // MARK: - Observation + + public func publisher(forKey key: Key) + async throws -> AnyPublisher? { + let storage: KeyValueCodingStorage = try await storage(for: key.domain) + let codingKey = KeyValueCodingStorageKey(key: key.key) + return await (storage as? KeyValueObservableStorage)?.publisher(forKey: codingKey) + } + + public func stream(forKey key: Key) + async throws -> AsyncStream? { + let storage: KeyValueCodingStorage = try await storage(for: key.domain) + let codingKey = KeyValueCodingStorageKey(key: key.key) + return await (storage as? KeyValueObservableStorage)?.stream(forKey: codingKey) + } + + // MARK: Helpers + + private func storage(for domain: Storage.Domain?) async throws -> KeyValueCodingStorage { + let underlyingKey = UnderlyingStorageKey(domain: domain) + if let storage = storages[underlyingKey], let casted = storage as? KeyValueCodingStorage { + return casted + } + + let dataStorage: Storage = try await factory.dataStorage(for: domain) + let codingStorage = try factory.codingStorage(for: dataStorage) + storages[underlyingKey] = codingStorage + + + return codingStorage + } +} + +extension UnifiedStorage { + private struct UnderlyingStorageKey: Hashable, Sendable { + let domain: Storage.Domain? + let storageType: Storage.Type + + init(domain: Storage.Domain?) { + self.domain = domain + self.storageType = Storage.self + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.domain == rhs.domain && + lhs.storageType == rhs.storageType + } + + func hash(into hasher: inout Hasher) { + hasher.combine(domain) + hasher.combine(String(describing: storageType)) + } + } +} diff --git a/Tests/KeyValueStorageTests/KeyValueStoragePropertyWrapperTests.swift b/Tests/KeyValueStorageTests/KeyValueStoragePropertyWrapperTests.swift index 52854e2..43844a4 100644 --- a/Tests/KeyValueStorageTests/KeyValueStoragePropertyWrapperTests.swift +++ b/Tests/KeyValueStorageTests/KeyValueStoragePropertyWrapperTests.swift @@ -6,7 +6,10 @@ // import XCTest +import SwiftUI @testable import KeyValueStorage +@testable import KeyValueStorageWrapper +@testable import KeyValueStorageSwiftUI #if os(macOS) @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) @@ -79,21 +82,21 @@ final class KeyValueStoragePropertyWrapperTests: XCTestCase { @Storage(key: key1) var int1: Int? @Storage(key: key2) var int2: Int? - let subscription1 = $int1.sink { value in + let subscription1 = _int1.publisher.sink { value in // Then XCTAssertEqual(int1, int2) XCTAssertEqual(int1, value) sink1Called = true } - let subscription2 = $int2.sink { value in + let subscription2 = _int2.publisher.sink { value in // Then XCTAssertEqual(int1, int2) XCTAssertEqual(value, int2) sink2Called = true } - let subscription3 = $int2.sink { value in + let subscription3 = _int2.publisher.sink { value in // Then XCTAssertEqual(int1, int2) XCTAssertEqual(value, int2) @@ -113,6 +116,25 @@ final class KeyValueStoragePropertyWrapperTests: XCTestCase { XCTAssertNotNil(subscription2) XCTAssertNotNil(subscription3) } + + func testBinding() { + // Given + let key = KeyValueStorageKey(name: "key", storage: .inMemory) + @ObservedStorage(key: key) var int: Int? + int = 10 + + // When + @Binding var bindedInt: Int? + _bindedInt = $int + + // Then + XCTAssertEqual(bindedInt, 10) + + // When + bindedInt = 77 + // Then + XCTAssertEqual(int, 77) + } } #endif diff --git a/Tests/UnifiedStorageTests/DataCoders/DataCodersTests.swift b/Tests/UnifiedStorageTests/DataCoders/DataCodersTests.swift new file mode 100644 index 0000000..0551909 --- /dev/null +++ b/Tests/UnifiedStorageTests/DataCoders/DataCodersTests.swift @@ -0,0 +1,61 @@ +// +// DataCodersTests.swift +// +// +// Created by Narek Sahakyan on 02.03.24. +// + +import XCTest +import Foundation +@testable import UnifiedStorage + +final class DataCodersTests: XCTestCase { + func testJSONCoding() async throws { + // Given + let coder = JSONDataCoder() + let codable1 = "rootObject" + let codable2 = ["rootObject"] + + // When + let encoded1 = try await coder.encode(codable1) + let encoded2 = try await coder.encode(codable2) + + // Then + XCTAssertFalse(encoded1.isEmpty) + XCTAssertFalse(encoded2.isEmpty) + + // When + let decoded1 = try await coder.decode(encoded1) as String + let decoded2 = try await coder.decode(encoded2) as [String] + + // Then + XCTAssertEqual(decoded1, codable1) + XCTAssertEqual(decoded2, codable2) + + + } + + func testXMLCoding() async throws { + // Given + let coder = XMLDataCoder() + let codable = ["rootObject"] + + // When + let encoded = try await coder.encode(codable) + + // Then + XCTAssertFalse(encoded.isEmpty) + + // When + let decoded = try await coder.decode(encoded) as [String] + + // Then + XCTAssertEqual(decoded, codable) + + // When - Then + do { + _ = try await coder.encode("rootObject") + XCTFail("XML parser cant decode root objects") + } catch { } + } +} diff --git a/Tests/UnifiedStorageTests/DataStorageTests/FileStorageTests.swift b/Tests/UnifiedStorageTests/DataStorageTests/FileStorageTests.swift new file mode 100644 index 0000000..b1c4040 --- /dev/null +++ b/Tests/UnifiedStorageTests/DataStorageTests/FileStorageTests.swift @@ -0,0 +1,603 @@ +// +// FileStorageTests.swift +// +// +// Created by Narek Sahakyan on 31.12.23. +// + +import XCTest +import Foundation +@testable import UnifiedStorage + +@FileActor +final class FileStorageTests: XCTestCase { + static let otherStorageDomain = "other" + var fileManager = FileManager.default + var standardPath: URL! + var otherPath: URL! + var standardStorage: FileStorage! + var otherStorage: FileStorage! + + override func setUpWithError() throws { + let id = Bundle.main.bundleIdentifier! + standardPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent(id, isDirectory: true) + otherPath = fileManager.containerURL(forSecurityApplicationGroupIdentifier: Self.otherStorageDomain)!.appendingPathComponent(id, isDirectory: true) + + try? fileManager.createDirectory(at: standardPath, withIntermediateDirectories: true) + try? fileManager.createDirectory(at: otherPath, withIntermediateDirectories: true) + try fileManager.clearDirectoryContents(url: standardPath) + try fileManager.clearDirectoryContents(url: otherPath) + + standardStorage = try FileStorage() + otherStorage = try FileStorage(domain: Self.otherStorageDomain) + } + + func testFileDomain() { + XCTAssertEqual(standardStorage.domain, nil) + XCTAssertEqual(otherStorage.domain, Self.otherStorageDomain) + } + + func testFileFetch() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + let filePath1 = standardPath.appendingPathComponent(key1).path + let filePath2 = standardPath.appendingPathComponent(key2).path + + // When + var fetched1 = try standardStorage.fetch(forKey: key1) + var fetched2 = try standardStorage.fetch(forKey: key2) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + + // When + XCTAssertTrue(fileManager.createFile(atPath: filePath1, contents: data1)) + fetched1 = try standardStorage.fetch(forKey: key1) + fetched2 = try standardStorage.fetch(forKey: key2) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertNil(fetched2) + + // When + try fileManager.removeItem(atPath: filePath1) + XCTAssertTrue(fileManager.createFile(atPath: filePath1, contents: data2)) + fetched1 = try standardStorage.fetch(forKey: key1) + fetched2 = try standardStorage.fetch(forKey: key2) + + // Then + XCTAssertEqual(fetched1, data2) + XCTAssertNil(fetched2) + + // When + try fileManager.removeItem(atPath: filePath1) + XCTAssertTrue(fileManager.createFile(atPath: filePath1, contents: data1)) + XCTAssertTrue(fileManager.createFile(atPath: filePath2, contents: data2)) + fetched1 = try standardStorage.fetch(forKey: key1) + fetched2 = try standardStorage.fetch(forKey: key2) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertEqual(fetched2, data2) + + // When + try fileManager.removeItem(atPath: filePath1) + try fileManager.removeItem(atPath: filePath2) + XCTAssertTrue(fileManager.createFile(atPath: filePath2, contents: data1)) + fetched1 = try standardStorage.fetch(forKey: key1) + fetched2 = try standardStorage.fetch(forKey: key2) + + // Then + XCTAssertNil(fetched1) + XCTAssertEqual(fetched2, data1) + + // When + try fileManager.removeItem(atPath: filePath2) + fetched1 = try standardStorage.fetch(forKey: key1) + fetched2 = try standardStorage.fetch(forKey: key2) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + } + + func testFileFetchDifferentDomains() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + let filePath1 = standardPath.appendingPathComponent(key).path + let filePath2 = otherPath.appendingPathComponent(key).path + + // When + var fetched1 = try standardStorage.fetch(forKey: key) + var fetched2 = try otherStorage.fetch(forKey: key) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + + // When + XCTAssertTrue(fileManager.createFile(atPath: filePath1, contents: data1)) + fetched1 = try standardStorage.fetch(forKey: key) + fetched2 = try otherStorage.fetch(forKey: key) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertNil(fetched2) + + // When + try fileManager.removeItem(atPath: filePath1) + XCTAssertTrue(fileManager.createFile(atPath: filePath2, contents: data2)) + fetched1 = try standardStorage.fetch(forKey: key) + fetched2 = try otherStorage.fetch(forKey: key) + + // Then + XCTAssertNil(fetched1) + XCTAssertEqual(fetched2, data2) + + // When + + XCTAssertTrue(fileManager.createFile(atPath: filePath1, contents: data2)) + try fileManager.removeItem(atPath: filePath2) + fetched1 = try standardStorage.fetch(forKey: key) + fetched2 = try otherStorage.fetch(forKey: key) + + // Then + XCTAssertEqual(fetched1, data2) + XCTAssertNil(fetched2) + + // When + try fileManager.removeItem(atPath: filePath1) + XCTAssertTrue(fileManager.createFile(atPath: filePath1, contents: data1)) + XCTAssertTrue(fileManager.createFile(atPath: filePath2, contents: data2)) + fetched1 = try standardStorage.fetch(forKey: key) + fetched2 = try otherStorage.fetch(forKey: key) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertEqual(fetched2, data2) + + // When + try fileManager.removeItem(atPath: filePath1) + try fileManager.removeItem(atPath: filePath2) + fetched1 = try standardStorage.fetch(forKey: key) + fetched2 = try otherStorage.fetch(forKey: key) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + } + + func testFileSave() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + let filePath1 = standardPath.appendingPathComponent(key1).path + let filePath2 = standardPath.appendingPathComponent(key2).path + + // When + try standardStorage.save(data1, forKey: key1) + + // Then + XCTAssertEqual(fileManager.contents(atPath: filePath1), data1) + XCTAssertNil(fileManager.contents(atPath: filePath2)) + + // When + try standardStorage.save(data2, forKey: key1) + try standardStorage.save(data2, forKey: key1) + try standardStorage.save(data2, forKey: key1) + try standardStorage.save(data2, forKey: key1) + + // Then + XCTAssertEqual(fileManager.contents(atPath: filePath1), data2) + XCTAssertNil(fileManager.contents(atPath: filePath2)) + + // When + try standardStorage.save(data1, forKey: key2) + + // Then + XCTAssertEqual(fileManager.contents(atPath: filePath1), data2) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data1) + } + + func testFileSaveDifferentDomains() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + let filePath1 = standardPath.appendingPathComponent(key).path + let filePath2 = otherPath.appendingPathComponent(key).path + + // When + try standardStorage.save(data1, forKey: key) + + // Then + XCTAssertEqual(fileManager.contents(atPath: filePath1), data1) + XCTAssertNil(fileManager.contents(atPath: filePath2)) + + // When + try otherStorage.save(data2, forKey: key) + + // Then + XCTAssertEqual(fileManager.contents(atPath: filePath1), data1) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data2) + + // When + try standardStorage.save(data2, forKey: key) + + // Then + XCTAssertEqual(fileManager.contents(atPath: filePath1), data2) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data2) + } + + func testFileDelete() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + let filePath1 = standardPath.appendingPathComponent(key1).path + let filePath2 = standardPath.appendingPathComponent(key2).path + XCTAssertTrue(fileManager.createFile(atPath: filePath1, contents: data1)) + XCTAssertTrue(fileManager.createFile(atPath: filePath2, contents: data2)) + + // When + try standardStorage.delete(forKey: key1) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data2) + + // When + try standardStorage.delete(forKey: key1) + try standardStorage.delete(forKey: key1) + try standardStorage.delete(forKey: key1) + try standardStorage.delete(forKey: key1) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data2) + + // When + try standardStorage.delete(forKey: key2) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertNil(fileManager.contents(atPath: filePath2)) + } + + func testFileDeleteDifferentDomains() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + let filePath1 = standardPath.appendingPathComponent(key).path + let filePath2 = otherPath.appendingPathComponent(key).path + XCTAssertTrue(fileManager.createFile(atPath: filePath1, contents: data1)) + XCTAssertTrue(fileManager.createFile(atPath: filePath2, contents: data2)) + + // When + try standardStorage.delete(forKey: key) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data2) + + // When + try standardStorage.delete(forKey: key) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data2) + + // When + try otherStorage.delete(forKey: key) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertNil(fileManager.contents(atPath: filePath2)) + } + + func testFileSet() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + let filePath1 = standardPath.appendingPathComponent(key1).path + let filePath2 = standardPath.appendingPathComponent(key2).path + XCTAssertTrue(fileManager.createFile(atPath: filePath1, contents: data1)) + XCTAssertTrue(fileManager.createFile(atPath: filePath2, contents: data2)) + + // When + try standardStorage.set(data2, forKey: key1) + + // Then + XCTAssertEqual(fileManager.contents(atPath: filePath1), data2) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data2) + + // When + try standardStorage.set(nil, forKey: key1) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data2) + + // When + try standardStorage.set(data1, forKey: key2) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data1) + + // When + try standardStorage.set(nil, forKey: key2) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertNil(fileManager.contents(atPath: filePath2)) + } + + func testFileSetDifferentDomains() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + let filePath1 = standardPath.appendingPathComponent(key).path + let filePath2 = otherPath.appendingPathComponent(key).path + XCTAssertTrue(fileManager.createFile(atPath: filePath1, contents: data1)) + XCTAssertTrue(fileManager.createFile(atPath: filePath2, contents: data2)) + + // When + try standardStorage.set(data2, forKey: key) + + // Then + XCTAssertEqual(fileManager.contents(atPath: filePath1), data2) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data2) + + // When + try standardStorage.set(nil, forKey: key) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data2) + + // When + try otherStorage.set(data1, forKey: key) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertEqual(fileManager.contents(atPath: filePath2), data1) + + // When + try otherStorage.set(nil, forKey: key) + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath1)) + XCTAssertNil(fileManager.contents(atPath: filePath2)) + } + + func testFileClear() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + let filePath11 = standardPath.appendingPathComponent(key1).path + let filePath12 = standardPath.appendingPathComponent(key2).path + let filePath21 = otherPath.appendingPathComponent(key1).path + let filePath22 = otherPath.appendingPathComponent(key2).path + XCTAssertTrue(fileManager.createFile(atPath: filePath11, contents: data1)) + XCTAssertTrue(fileManager.createFile(atPath: filePath12, contents: data2)) + XCTAssertTrue(fileManager.createFile(atPath: filePath21, contents: data1)) + XCTAssertTrue(fileManager.createFile(atPath: filePath22, contents: data2)) + + // When + try standardStorage.clear() + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath11)) + XCTAssertNil(fileManager.contents(atPath: filePath12)) + XCTAssertEqual(fileManager.contents(atPath: filePath21), data1) + XCTAssertEqual(fileManager.contents(atPath: filePath22), data2) + + // When + try standardStorage.clear() + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath11)) + XCTAssertNil(fileManager.contents(atPath: filePath12)) + XCTAssertEqual(fileManager.contents(atPath: filePath21), data1) + XCTAssertEqual(fileManager.contents(atPath: filePath22), data2) + + // When + XCTAssertTrue(fileManager.createFile(atPath: filePath11, contents: data2)) + XCTAssertTrue(fileManager.createFile(atPath: filePath12, contents: data1)) + try otherStorage.clear() + + // Then + XCTAssertEqual(fileManager.contents(atPath: filePath11), data2) + XCTAssertEqual(fileManager.contents(atPath: filePath12), data1) + XCTAssertNil(fileManager.contents(atPath: filePath21)) + XCTAssertNil(fileManager.contents(atPath: filePath22)) + + // When + try standardStorage.clear() + + // Then + XCTAssertNil(fileManager.contents(atPath: filePath11)) + XCTAssertNil(fileManager.contents(atPath: filePath12)) + XCTAssertNil(fileManager.contents(atPath: filePath21)) + XCTAssertNil(fileManager.contents(atPath: filePath22)) + } + + func testErrorCaseDelete() { + // Given + let mock = FileManagerMock() + let storage = FileStorage(fileManager: mock, root: URL(string: "root")!) + mock.removeItemError = nil + + do { + // When + try storage.delete(forKey: "nonExistingFile") + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.removeItemError = CocoaError(CocoaError.fileNoSuchFile) + + do { + // When + try storage.delete(forKey: "nonExistingFile") + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.removeItemError = CocoaError(CocoaError.coderInvalidValue) + + do { + // When + try storage.delete(forKey: "nonExistingFile") + } catch let error as FileStorage.Error { + // Then + if case let .other(innerError as CocoaError) = error { + XCTAssertEqual(innerError.code, CocoaError.coderInvalidValue) + } else { + XCTFail("Unexpected error") + } + } catch { + // Then + XCTFail("Unexpected error") + } + } + + func testErrorCaseSave() { + // Given + let mock = FileManagerMock() + let storage = FileStorage(fileManager: mock, root: URL(string: "root")!) + mock.createDirectoryError = nil + mock.createFileError = nil + + do { + // When + try storage.save(.init(), forKey: "nonExistingFile") + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.createDirectoryError = CocoaError(CocoaError.fileWriteFileExists) + mock.createFileError = nil + + do { + // When + try storage.save(.init(), forKey: "nonExistingFile") + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.createDirectoryError = CocoaError(CocoaError.coderInvalidValue) + mock.createFileError = nil + + do { + // When + try storage.save(.init(), forKey: "nonExistingFile") + } catch let error as FileStorage.Error { + // Then + if case let .other(innerError as CocoaError) = error { + XCTAssertEqual(innerError.code, CocoaError.coderInvalidValue) + } else { + XCTFail("Unexpected error") + } + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.createDirectoryError = nil + mock.createFileError = CocoaError(CocoaError.coderInvalidValue) + + do { + // When + try storage.save(.init(), forKey: "nonExistingFile") + } catch let error as FileStorage.Error { + // Then + if case .failedToSave = error { + // ok + } else { + XCTFail("Unexpected error") + } + } catch { + // Then + XCTFail("Unexpected error") + } + } + + func testThreadSafety() throws { + // Given + let iterations = 5000 + let promise = expectation(description: "testThreadSafety") + let group = DispatchGroup() + for _ in 1...iterations { group.enter() } + + // When + DispatchQueue.concurrentPerform(iterations: iterations) { number in + let operation = Int.random(in: 0...4) + let key = "\(Int.random(in: 1000...9999))" + + Task.detached { + do { + switch operation { + case 0: + _ = try await self.standardStorage.fetch(forKey: key) + case 1: + try await self.standardStorage.save(.init(), forKey: key) + case 2: + try await self.standardStorage.delete(forKey: key) + case 3: + try await self.standardStorage.set(Bool.random() ? nil : .init(), forKey: key) + case 4: + try await self.standardStorage.clear() + default: + break + } + } catch { + XCTFail("Unexpected error") + } + + group.leave() + } + } + + group.notify(queue: .main) { + promise.fulfill() + } + + wait(for: [promise], timeout: 5) + } +} + +extension FileManager { + func clearDirectoryContents(url: URL) throws { + if let paths = try? contentsOfDirectory(atPath: url.path) { + for path in paths { + try removeItem(at: url.appendingPathComponent(path)) + } + } + } +} diff --git a/Tests/UnifiedStorageTests/DataStorageTests/InMemoryStorageTests.swift b/Tests/UnifiedStorageTests/DataStorageTests/InMemoryStorageTests.swift new file mode 100644 index 0000000..07389e0 --- /dev/null +++ b/Tests/UnifiedStorageTests/DataStorageTests/InMemoryStorageTests.swift @@ -0,0 +1,420 @@ +// +// InMemoryStorageTests.swift +// +// +// Created by Narek Sahakyan on 31.12.23. +// + +import XCTest +import Foundation +@testable import UnifiedStorage + +@InMemoryActor +final class InMemoryStorageTests: XCTestCase { + static let otherStorageDomain = "other" + var standardStorage: InMemoryStorage! + var otherStorage: InMemoryStorage! + + override func setUp() { + standardStorage = InMemoryStorage() + otherStorage = InMemoryStorage(domain: Self.otherStorageDomain) + + InMemoryStorage.container = [:] + } + + func testInMemoryDomain() { + XCTAssertEqual(standardStorage.domain, nil) + XCTAssertEqual(otherStorage.domain, Self.otherStorageDomain) + } + + func testInMemoryFetch() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + + // When + var fetched1 = standardStorage.fetch(forKey: key1) + var fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + + // When + InMemoryStorage.container = [nil: [key1: data1]] + fetched1 = standardStorage.fetch(forKey: key1) + fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertNil(fetched2) + + // When + InMemoryStorage.container = [nil: [key1: data2]] + fetched1 = standardStorage.fetch(forKey: key1) + fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertEqual(fetched1, data2) + XCTAssertNil(fetched2) + + // When + InMemoryStorage.container = [nil: [key1: data1, key2: data2]] + fetched1 = standardStorage.fetch(forKey: key1) + fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertEqual(fetched2, data2) + + // When + InMemoryStorage.container = [nil: [key2: data1]] + fetched1 = standardStorage.fetch(forKey: key1) + fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertNil(fetched1) + XCTAssertEqual(fetched2, data1) + + // When + InMemoryStorage.container = [nil: [:]] + fetched1 = standardStorage.fetch(forKey: key1) + fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + } + + func testInMemoryFetchDifferentDomains() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + + // When + var fetched1 = standardStorage.fetch(forKey: key) + var fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + + // When + InMemoryStorage.container = [nil: [key: data1]] + fetched1 = standardStorage.fetch(forKey: key) + fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertNil(fetched2) + + // When + InMemoryStorage.container = ["other": [key: data2]] + fetched1 = standardStorage.fetch(forKey: key) + fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertNil(fetched1) + XCTAssertEqual(fetched2, data2) + + // When + InMemoryStorage.container = [nil: [key: data2]] + fetched1 = standardStorage.fetch(forKey: key) + fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertEqual(fetched1, data2) + XCTAssertNil(fetched2) + + // When + InMemoryStorage.container = [nil: [key: data1], "other": [key: data2]] + fetched1 = standardStorage.fetch(forKey: key) + fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertEqual(fetched2, data2) + + // When + InMemoryStorage.container = [nil: [:], "other": [:]] + fetched1 = standardStorage.fetch(forKey: key) + fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + } + + func testInMemorySave() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + + // When + standardStorage.save(data1, forKey: key1) + + // Then + XCTAssertEqual(InMemoryStorage.container[nil]?[key1], data1) + XCTAssertNil(InMemoryStorage.container[nil]?[key2]) + + // When + standardStorage.save(data2, forKey: key1) + + // Then + XCTAssertEqual(InMemoryStorage.container[nil]?[key1], data2) + XCTAssertNil(InMemoryStorage.container[nil]?[key2]) + + // When + standardStorage.save(data1, forKey: key2) + + // Then + XCTAssertEqual(InMemoryStorage.container[nil]?[key1], data2) + XCTAssertEqual(InMemoryStorage.container[nil]?[key2], data1) + } + + func testInMemorySaveDifferentDomains() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + + // When + standardStorage.save(data1, forKey: key) + + // Then + XCTAssertEqual(InMemoryStorage.container[nil]?[key], data1) + XCTAssertNil(InMemoryStorage.container["other"]?[key]) + + // When + otherStorage.save(data2, forKey: key) + + // Then + XCTAssertEqual(InMemoryStorage.container[nil]?[key], data1) + XCTAssertEqual(InMemoryStorage.container["other"]?[key], data2) + + // When + standardStorage.save(data2, forKey: key) + + // Then + XCTAssertEqual(InMemoryStorage.container[nil]?[key], data2) + XCTAssertEqual(InMemoryStorage.container["other"]?[key], data2) + } + + func testInMemoryDelete() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + InMemoryStorage.container = [nil: [key1: data1, key2: data2]] + + // When + standardStorage.delete(forKey: key1) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key1]) + XCTAssertEqual(InMemoryStorage.container[nil]?[key2], data2) + + // When + standardStorage.delete(forKey: key1) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key1]) + XCTAssertEqual(InMemoryStorage.container[nil]?[key2], data2) + + // When + standardStorage.delete(forKey: key2) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key1]) + XCTAssertNil(InMemoryStorage.container[nil]?[key2]) + } + + func testInMemoryDeleteDifferentDomains() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + InMemoryStorage.container = [nil: [key: data1], "other": [key: data2]] + + // When + standardStorage.delete(forKey: key) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key]) + XCTAssertEqual(InMemoryStorage.container["other"]?[key], data2) + + // When + standardStorage.delete(forKey: key) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key]) + XCTAssertEqual(InMemoryStorage.container["other"]?[key], data2) + + // When + otherStorage.delete(forKey: key) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key]) + XCTAssertNil(InMemoryStorage.container["other"]?[key]) + } + + func testInMemorySet() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + InMemoryStorage.container = [nil: [key1: data1, key2: data2]] + + // When + standardStorage.set(data2, forKey: key1) + + // Then + XCTAssertEqual(InMemoryStorage.container[nil]?[key1], data2) + XCTAssertEqual(InMemoryStorage.container[nil]?[key2], data2) + + // When + standardStorage.set(nil, forKey: key1) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key1]) + XCTAssertEqual(InMemoryStorage.container[nil]?[key2], data2) + + // When + standardStorage.set(data1, forKey: key2) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key1]) + XCTAssertEqual(InMemoryStorage.container[nil]?[key2], data1) + + // When + standardStorage.set(nil, forKey: key2) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key1]) + XCTAssertNil(InMemoryStorage.container[nil]?[key2]) + } + + func testInMemorySetDifferentDomains() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + InMemoryStorage.container = [nil: [key: data1], "other": [key: data2]] + + // When + standardStorage.set(data2, forKey: key) + + // Then + XCTAssertEqual(InMemoryStorage.container[nil]?[key], data2) + XCTAssertEqual(InMemoryStorage.container["other"]?[key], data2) + + // When + standardStorage.set(nil, forKey: key) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key]) + XCTAssertEqual(InMemoryStorage.container["other"]?[key], data2) + + // When + otherStorage.set(data1, forKey: key) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key]) + XCTAssertEqual(InMemoryStorage.container["other"]?[key], data1) + + // When + otherStorage.set(nil, forKey: key) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?[key]) + XCTAssertNil(InMemoryStorage.container["other"]?[key]) + } + + func testInMemoryClear() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + InMemoryStorage.container = [nil: [key1: data1, key2: data2], "other": [key1: data2, key2: data1]] + + // When + standardStorage.clear() + + // Then + XCTAssertEqual(InMemoryStorage.container[nil], [:]) + XCTAssertEqual(InMemoryStorage.container["other"]?[key1], data2) + XCTAssertEqual(InMemoryStorage.container["other"]?[key2], data1) + + // When + standardStorage.clear() + + // Then + XCTAssertEqual(InMemoryStorage.container[nil], [:]) + XCTAssertEqual(InMemoryStorage.container["other"]?[key1], data2) + XCTAssertEqual(InMemoryStorage.container["other"]?[key2], data1) + + // When + InMemoryStorage.container = [nil: [key1: data1, key2: data2], "other": [key1: data2, key2: data1]] + otherStorage.clear() + + // Then + XCTAssertEqual(InMemoryStorage.container[nil]?[key1], data1) + XCTAssertEqual(InMemoryStorage.container[nil]?[key2], data2) + XCTAssertEqual(InMemoryStorage.container["other"], [:]) + + + // When + standardStorage.clear() + + // Then + XCTAssertEqual(InMemoryStorage.container[nil], [:]) + XCTAssertEqual(InMemoryStorage.container["other"], [:]) + } + + func testThreadSafety() { + // Given + let iterations = 5000 + let promise = expectation(description: "testThreadSafety") + let group = DispatchGroup() + for _ in 1...iterations { group.enter() } + + // When + DispatchQueue.concurrentPerform(iterations: iterations) { number in + let operation = Int.random(in: 0...4) + let key = "\(Int.random(in: 1000...9999))" + + Task.detached { + switch operation { + case 0: + _ = await self.standardStorage.fetch(forKey: key) + case 1: + await self.standardStorage.save(.init(), forKey: key) + case 2: + await self.standardStorage.delete(forKey: key) + case 3: + await self.standardStorage.set(Bool.random() ? nil : .init(), forKey: key) + case 4: + await self.standardStorage.clear() + default: + break + } + + group.leave() + } + } + + group.notify(queue: .main) { + promise.fulfill() + } + + wait(for: [promise], timeout: 5) + } +} diff --git a/Tests/UnifiedStorageTests/DataStorageTests/KeychainStorageTests.swift b/Tests/UnifiedStorageTests/DataStorageTests/KeychainStorageTests.swift new file mode 100644 index 0000000..a08f124 --- /dev/null +++ b/Tests/UnifiedStorageTests/DataStorageTests/KeychainStorageTests.swift @@ -0,0 +1,715 @@ +// +// KeychainStorageTests.swift +// +// +// Created by Narek Sahakyan on 31.12.23. +// + +import XCTest +import Foundation +@testable import UnifiedStorage + +@KeychainActor +final class KeychainStorageTests: XCTestCase { + static let otherStorageDomain = KeychainStorage.Domain(groupId: "xxx", teamId: "yyy") + var standardKeychain: KeychainHelper! + var otherKeychain: KeychainHelper! + var standardStorage: KeychainStorage! + var otherStorage: KeychainStorage! + + #if SWIFT_PACKAGE_CAN_ATTACH_ENTITLRMENTS + + override func setUpWithError() throws { + standardKeychain = KeychainHelper(serviceName: Bundle.main.bundleIdentifier!) + otherKeychain = KeychainHelper(serviceName: Bundle.main.bundleIdentifier!, accessGroup: Self.otherStorageDomain.accessGroup) + + try standardKeychain.removeAll() + try otherKeychain.removeAll() + + standardStorage = KeychainStorage() + otherStorage = KeychainStorage(domain: Self.otherStorageDomain) + + } + + func testKeychainDomain() { + XCTAssertEqual(standardStorage.domain, nil) + XCTAssertEqual(otherStorage.domain, Self.otherStorageDomain) + } + + func testKeychainFetch() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + let storageKey1 = KeychainStorage.Key(name: key1) + let storageKey2 = KeychainStorage.Key(name: key2) + + // When + var fetched1 = try standardStorage.fetch(forKey: storageKey1) + var fetched2 = try standardStorage.fetch(forKey: storageKey2) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + + // When + try standardKeychain.set(data1, forKey: key1) + fetched1 = try standardStorage.fetch(forKey: storageKey1) + fetched2 = try standardStorage.fetch(forKey: storageKey2) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertNil(fetched2) + + // When + try standardKeychain.set(data2, forKey: key1) + fetched1 = try standardStorage.fetch(forKey: storageKey1) + fetched2 = try standardStorage.fetch(forKey: storageKey2) + + // Then + XCTAssertEqual(fetched1, data2) + XCTAssertNil(fetched2) + + // When + try standardKeychain.set(data1, forKey: key1) + try standardKeychain.set(data2, forKey: key2) + fetched1 = try standardStorage.fetch(forKey: storageKey1) + fetched2 = try standardStorage.fetch(forKey: storageKey2) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertEqual(fetched2, data2) + + // When + try standardKeychain.set(data1, forKey: key2) + try standardKeychain.remove(forKey: key1) + fetched1 = try standardStorage.fetch(forKey: storageKey1) + fetched2 = try standardStorage.fetch(forKey: storageKey2) + + // Then + XCTAssertNil(fetched1) + XCTAssertEqual(fetched2, data1) + + // When + try standardKeychain.removeAll() + fetched1 = try standardStorage.fetch(forKey: storageKey1) + fetched2 = try standardStorage.fetch(forKey: storageKey2) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + } + + func testKeychainFetchDifferentDomains() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + let storageKey = KeychainStorage.Key(name: key) + try standardKeychain.removeAll() + try otherKeychain.removeAll() + + // When + var fetched1 = try standardStorage.fetch(forKey: storageKey) + var fetched2 = try otherStorage.fetch(forKey: storageKey) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + + // When + try standardKeychain.set(data1, forKey: key) + fetched1 = try standardStorage.fetch(forKey: storageKey) + fetched2 = try otherStorage.fetch(forKey: storageKey) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertNil(fetched2) + + // When + try standardKeychain.remove(forKey: key) + try otherKeychain.set(data2, forKey: key) + fetched1 = try standardStorage.fetch(forKey: storageKey) + fetched2 = try otherStorage.fetch(forKey: storageKey) + + // Then + XCTAssertNil(fetched1) + XCTAssertEqual(fetched2, data2) + + // When + try standardKeychain.set(data2, forKey: key) + try otherKeychain.remove(forKey: key) + fetched1 = try standardStorage.fetch(forKey: storageKey) + fetched2 = try otherStorage.fetch(forKey: storageKey) + + // Then + XCTAssertEqual(fetched1, data2) + XCTAssertNil(fetched2) + + // When + try standardKeychain.set(data1, forKey: key) + try otherKeychain.set(data2, forKey: key) + fetched1 = try standardStorage.fetch(forKey: storageKey) + fetched2 = try otherStorage.fetch(forKey: storageKey) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertEqual(fetched2, data2) + + // When + try standardKeychain.remove(forKey: key) + try otherKeychain.remove(forKey: key) + fetched1 = try standardStorage.fetch(forKey: storageKey) + fetched2 = try otherStorage.fetch(forKey: storageKey) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + } + + func testKeychainSave() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + let storageKey1 = KeychainStorage.Key(name: key1) + let storageKey2 = KeychainStorage.Key(name: key2) + + // When + try standardStorage.save(data1, forKey: storageKey1) + + // Then + XCTAssertEqual(try standardKeychain.get(forKey: key1), data1) + XCTAssertNil(try standardKeychain.get(forKey: key2)) + + // When + try standardStorage.save(data2, forKey: storageKey1) + + // Then + XCTAssertEqual(try standardKeychain.get(forKey: key1), data2) + XCTAssertNil(try standardKeychain.get(forKey: key2)) + + // When + try standardStorage.save(data1, forKey: storageKey2) + + // Then + XCTAssertEqual(try standardKeychain.get(forKey: key1), data2) + XCTAssertEqual(try standardKeychain.get(forKey: key2), data1) + } + + func testKeychainSaveDifferentDomains() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + let storageKey = KeychainStorage.Key(name: key) + + // When + try standardStorage.save(data1, forKey: storageKey) + + // Then + XCTAssertEqual(try standardKeychain.get(forKey: key), data1) + XCTAssertNil(try otherKeychain.get(forKey: key)) + + // When + try otherStorage.save(data2, forKey: storageKey) + + // Then + XCTAssertEqual(try standardKeychain.get(forKey: key), data1) + XCTAssertEqual(try otherKeychain.get(forKey: key), data2) + + // When + try standardStorage.save(data2, forKey: storageKey) + + // Then + XCTAssertEqual(try standardKeychain.get(forKey: key), data2) + XCTAssertEqual(try otherKeychain.get(forKey: key), data2) + } + + func testKeychainDelete() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + let storageKey1 = KeychainStorage.Key(name: key1) + let storageKey2 = KeychainStorage.Key(name: key2) + try standardKeychain.set(data1, forKey: key1) + try standardKeychain.set(data2, forKey: key2) + + // When + try standardStorage.delete(forKey: storageKey1) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key1)) + XCTAssertEqual(try standardKeychain.get(forKey: key2), data2) + + // When + try standardStorage.delete(forKey: storageKey1) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key1)) + XCTAssertEqual(try standardKeychain.get(forKey: key2), data2) + + // When + try standardStorage.delete(forKey: storageKey2) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key1)) + XCTAssertNil(try standardKeychain.get(forKey: key2)) + } + + func testKeychainDeleteDifferentDomains() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + let storageKey = KeychainStorage.Key(name: key) + try standardKeychain.set(data1, forKey: key) + try otherKeychain.set(data2, forKey: key) + + // When + try standardStorage.delete(forKey: storageKey) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key)) + XCTAssertEqual(try otherKeychain.get(forKey: key), data2) + + // When + try standardStorage.delete(forKey: storageKey) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key)) + XCTAssertEqual(try otherKeychain.get(forKey: key), data2) + + // When + try otherStorage.delete(forKey: storageKey) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key)) + XCTAssertNil(try otherKeychain.get(forKey: key)) + } + + func testKeychainSet() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + let storageKey1 = KeychainStorage.Key(name: key1) + let storageKey2 = KeychainStorage.Key(name: key2) + try standardKeychain.set(data1, forKey: key1) + try standardKeychain.set(data2, forKey: key2) + + // When + try standardStorage.set(data2, forKey: storageKey1) + + // Then + XCTAssertEqual(try standardKeychain.get(forKey: key1), data2) + XCTAssertEqual(try standardKeychain.get(forKey: key2), data2) + + // When + try standardStorage.set(nil, forKey: storageKey1) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key1)) + XCTAssertEqual(try standardKeychain.get(forKey: key2), data2) + + // When + try standardStorage.set(data1, forKey: storageKey2) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key1)) + XCTAssertEqual(try standardKeychain.get(forKey: key2), data1) + + // When + try standardStorage.set(nil, forKey: storageKey2) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key1)) + XCTAssertNil(try standardKeychain.get(forKey: key2)) + } + + func testKeychainSetDifferentDomains() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + let storageKey = KeychainStorage.Key(name: key) + try standardKeychain.set(data1, forKey: key) + try otherKeychain.set(data2, forKey: key) + + // When + try standardStorage.set(data2, forKey: storageKey) + + // Then + XCTAssertEqual(try standardKeychain.get(forKey: key), data2) + XCTAssertEqual(try otherKeychain.get(forKey: key), data2) + + // When + try standardStorage.set(nil, forKey: storageKey) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key)) + XCTAssertEqual(try otherKeychain.get(forKey: key), data2) + + // When + try otherStorage.set(data1, forKey: storageKey) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key)) + XCTAssertEqual(try otherKeychain.get(forKey: key), data1) + + // When + try otherStorage.set(nil, forKey: storageKey) + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key)) + XCTAssertNil(try otherKeychain.get(forKey: key)) + } + + func testKeychainClear() throws { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + try standardKeychain.set(data1, forKey: key1) + try standardKeychain.set(data2, forKey: key2) + try otherKeychain.set(data1, forKey: key1) + try otherKeychain.set(data2, forKey: key2) + + // When + try standardStorage.clear() + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key1)) + XCTAssertNil(try standardKeychain.get(forKey: key2)) + XCTAssertEqual(try otherKeychain.get(forKey: key1), data1) + XCTAssertEqual(try otherKeychain.get(forKey: key2), data2) + + // When + try standardStorage.clear() + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key1)) + XCTAssertNil(try standardKeychain.get(forKey: key2)) + XCTAssertEqual(try otherKeychain.get(forKey: key1), data1) + XCTAssertEqual(try otherKeychain.get(forKey: key2), data2) + + // When + try standardKeychain.set(data2, forKey: key1) + try standardKeychain.set(data1, forKey: key2) + try otherStorage.clear() + + // Then + XCTAssertEqual(try standardKeychain.get(forKey: key1), data2) + XCTAssertEqual(try standardKeychain.get(forKey: key2), data1) + XCTAssertNil(try otherKeychain.get(forKey: key1)) + XCTAssertNil(try otherKeychain.get(forKey: key2)) + + // When + try standardStorage.clear() + + // Then + XCTAssertNil(try standardKeychain.get(forKey: key1)) + XCTAssertNil(try standardKeychain.get(forKey: key2)) + XCTAssertNil(try otherKeychain.get(forKey: key1)) + XCTAssertNil(try otherKeychain.get(forKey: key2)) + } + + func testThreadSafety() throws { + // Given + let iterations = 5000 + let promise = expectation(description: "testThreadSafety") + let group = DispatchGroup() + for _ in 1...iterations { group.enter() } + + // When + DispatchQueue.concurrentPerform(iterations: iterations) { number in + let operation = Int.random(in: 0...4) + let key = KeychainStorage.Key(name: "\(Int.random(in: 1000...9999))") + + Task.detached { + do { + switch operation { + case 0: + _ = try await self.standardStorage.fetch(forKey: key) + case 1: + try await self.standardStorage.save(.init(), forKey: key) + case 2: + try await self.standardStorage.delete(forKey: key) + case 3: + try await self.standardStorage.set(Bool.random() ? nil : .init(), forKey: key) + case 4: + try await self.standardStorage.clear() + default: + break + } + } catch { + XCTFail("Unexpected error") + } + + group.leave() + } + } + + group.notify(queue: .main) { + promise.fulfill() + } + + wait(for: [promise], timeout: 5) + } + + #endif + + func testDomain() { + // Given + let domain = KeychainStorage.Domain(groupId: "a", teamId: "b") + + // When + let group = domain.accessGroup + + // Then + XCTAssertEqual(group, "b.a") + } + + func testInitDomains() { + // When + var storage = KeychainStorage() + + // Then + XCTAssertNil(storage.domain) + + // When + storage = KeychainStorage(domain: .init(groupId: "a", teamId: "b")) + + // Then + XCTAssertEqual(storage.domain, .init(groupId: "a", teamId: "b")) + } + + func testInitCustomKeychain() { + // Given + let keychain = KeychainHelper(serviceName: "mock") + + // When + let storage = KeychainStorage(keychain: keychain) + + // Then + XCTAssertNil(storage.domain) + } + + func testMockedFetch() { + // Given + let mock = KeychainHelperMock(serviceName: "mock") + let storage = KeychainStorage(keychain: mock) + mock.getError = KeychainHelperError.status(.max) + + do { + // When + _ = try storage.fetch(forKey: .init(name: "nonExistingFile")) + } catch let error as KeychainStorage.Error { + // Then + if case .os(.max) = error { + // ok + } else { + XCTFail("Unexpected error") + } + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.getError = CocoaError(CocoaError.fileNoSuchFile) + + do { + // When + _ = try storage.fetch(forKey: .init(name: "nonExistingFile")) + } catch let error as KeychainStorage.Error { + // Then + if case let .other(inner) = error, let cocoa = inner as? CocoaError, cocoa.code == CocoaError.fileNoSuchFile { + // ok + } else { + XCTFail("Unexpected error") + } + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.getError = nil + + do { + // When + _ = try storage.fetch(forKey: .init(name: "nonExistingFile")) + } catch { + // Then + XCTFail("Unexpected error") + } + } + + func testMockedSave() { + // Given + let mock = KeychainHelperMock(serviceName: "mock") + let storage = KeychainStorage(keychain: mock) + mock.setError = .status(.max) + + do { + // When + try storage.save(.init(), forKey: .init(name: "nonExistingFile")) + } catch let error as KeychainStorage.Error { + // Then + if case .os(.max) = error { + // ok + } else { + XCTFail("Unexpected error") + } + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.setError = nil + + do { + // When + try storage.save(.init(), forKey: .init(name: "nonExistingFile")) + } catch { + // Then + XCTFail("Unexpected error") + } + } + + func testMockedDelete() { + // Given + let mock = KeychainHelperMock(serviceName: "mock") + let storage = KeychainStorage(keychain: mock) + mock.removeError = .status(.max) + + do { + // When + try storage.delete(forKey: .init(name: "nonExistingFile")) + } catch let error as KeychainStorage.Error { + // Then + if case .os(.max) = error { + // ok + } else { + XCTFail("Unexpected error") + } + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.removeError = nil + + do { + // When + try storage.delete(forKey: .init(name: "nonExistingFile")) + } catch { + // Then + XCTFail("Unexpected error") + } + } + + func testMockedSet() { + // Given + let mock = KeychainHelperMock(serviceName: "mock") + let storage = KeychainStorage(keychain: mock) + mock.removeError = .status(.max) + + do { + // When + try storage.set(nil, forKey: .init(name: "nonExistingFile")) + } catch let error as KeychainStorage.Error { + // Then + if case .os(.max) = error { + // ok + } else { + XCTFail("Unexpected error") + } + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.removeError = nil + + do { + // When + try storage.set(nil, forKey: .init(name: "nonExistingFile")) + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.setError = .status(.max) + + do { + // When + try storage.set(.init(), forKey: .init(name: "nonExistingFile")) + } catch let error as KeychainStorage.Error { + // Then + if case .os(.max) = error { + // ok + } else { + XCTFail("Unexpected error") + } + } catch { + // Then + XCTFail("Unexpected error") + } + + // When + mock.setError = nil + + do { + // When + try storage.set(.init(), forKey: .init(name: "nonExistingFile")) + } catch { + // Then + XCTFail("Unexpected error") + } + } + + func testMockedClear() { + // Given + let mock = KeychainHelperMock(serviceName: "mock") + let storage = KeychainStorage(keychain: mock) + mock.removeAllError = .status(.max) + + do { + // When + try storage.clear() + } catch let error as KeychainStorage.Error { + // Then + if case .os(.max) = error { + // ok + } else { + XCTFail("Unexpected error") + } + } catch { + // Then + XCTFail("Unexpected error") + } + + // Given + mock.removeAllError = nil + + do { + // When + try storage.clear() + } catch { + // Then + XCTFail("Unexpected error") + } + } +} diff --git a/Tests/UnifiedStorageTests/DataStorageTests/UserDefaultsStorageTests.swift b/Tests/UnifiedStorageTests/DataStorageTests/UserDefaultsStorageTests.swift new file mode 100644 index 0000000..39971e1 --- /dev/null +++ b/Tests/UnifiedStorageTests/DataStorageTests/UserDefaultsStorageTests.swift @@ -0,0 +1,462 @@ +// +// UserDefaultsStorageTests.swift +// +// +// Created by Narek Sahakyan on 31.12.23. +// + +import XCTest +import Foundation +@testable import UnifiedStorage + +@UserDefaultsActor +final class UserDefaultsStorageTests: XCTestCase { + static let otherStorageDomain = "other" + var standardUserDefaults: UserDefaults! + var otherUserDefaults: UserDefaults! + var standardStorage: UserDefaultsStorage! + var otherStorage: UserDefaultsStorage! + + override func setUpWithError() throws { + standardUserDefaults = UserDefaults.standard + otherUserDefaults = UserDefaults(suiteName: Self.otherStorageDomain)! + + standardUserDefaults.clearStandardStorage() + otherUserDefaults.removePersistentDomain(forName: Self.otherStorageDomain) + + standardStorage = UserDefaultsStorage() + otherStorage = try UserDefaultsStorage(domain: Self.otherStorageDomain) + } + + func testUserDefaultsDomain() { + XCTAssertEqual(standardStorage.domain, nil) + XCTAssertEqual(otherStorage.domain, Self.otherStorageDomain) + } + + func testUserDefaultsFetch() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + + // When + var fetched1 = standardStorage.fetch(forKey: key1) + var fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + + // When + standardUserDefaults.set(data1, forKey: key1) + fetched1 = standardStorage.fetch(forKey: key1) + fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertNil(fetched2) + + // When + standardUserDefaults.set(data2, forKey: key1) + fetched1 = standardStorage.fetch(forKey: key1) + fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertEqual(fetched1, data2) + XCTAssertNil(fetched2) + + // When + standardUserDefaults.set(data1, forKey: key1) + standardUserDefaults.set(data2, forKey: key2) + fetched1 = standardStorage.fetch(forKey: key1) + fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertEqual(fetched2, data2) + + // When + standardUserDefaults.set(data1, forKey: key2) + standardUserDefaults.set(nil, forKey: key1) + fetched1 = standardStorage.fetch(forKey: key1) + fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertNil(fetched1) + XCTAssertEqual(fetched2, data1) + + // When + standardUserDefaults.clearStandardStorage() + fetched1 = standardStorage.fetch(forKey: key1) + fetched2 = standardStorage.fetch(forKey: key2) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + } + + func testUserDefaultsFetchDifferentDomains() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + + // When + var fetched1 = standardStorage.fetch(forKey: key) + var fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + + // When + standardUserDefaults.set(data1, forKey: key) + fetched1 = standardStorage.fetch(forKey: key) + fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertNil(fetched2) + + // When + standardUserDefaults.removeObject(forKey: key) + otherUserDefaults.set(data2, forKey: key) + fetched1 = standardStorage.fetch(forKey: key) + fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertNil(fetched1) + XCTAssertEqual(fetched2, data2) + + // When + standardUserDefaults.set(data2, forKey: key) + otherUserDefaults.removeObject(forKey: key) + fetched1 = standardStorage.fetch(forKey: key) + fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertEqual(fetched1, data2) + XCTAssertNil(fetched2) + + // When + standardUserDefaults.set(data1, forKey: key) + otherUserDefaults.set(data2, forKey: key) + fetched1 = standardStorage.fetch(forKey: key) + fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertEqual(fetched1, data1) + XCTAssertEqual(fetched2, data2) + + // When + standardUserDefaults.removeObject(forKey: key) + otherUserDefaults.removeObject(forKey: key) + fetched1 = standardStorage.fetch(forKey: key) + fetched2 = otherStorage.fetch(forKey: key) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + } + + func testUserDefaultsSave() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + + // When + standardStorage.save(data1, forKey: key1) + + // Then + XCTAssertEqual(standardUserDefaults.data(forKey: key1), data1) + XCTAssertNil(standardUserDefaults.data(forKey: key2)) + + // When + standardStorage.save(data2, forKey: key1) + + // Then + XCTAssertEqual(standardUserDefaults.data(forKey: key1), data2) + XCTAssertNil(standardUserDefaults.data(forKey: key2)) + + // When + standardStorage.save(data1, forKey: key2) + + // Then + XCTAssertEqual(standardUserDefaults.data(forKey: key1), data2) + XCTAssertEqual(standardUserDefaults.data(forKey: key2), data1) + } + + func testUserDefaultsSaveDifferentDomains() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + + // When + standardStorage.save(data1, forKey: key) + + // Then + XCTAssertEqual(standardUserDefaults.data(forKey: key), data1) + XCTAssertNil(otherUserDefaults.data(forKey: key)) + + // When + otherStorage.save(data2, forKey: key) + + // Then + XCTAssertEqual(standardUserDefaults.data(forKey: key), data1) + XCTAssertEqual(otherUserDefaults.data(forKey: key), data2) + + // When + standardStorage.save(data2, forKey: key) + + // Then + XCTAssertEqual(standardUserDefaults.data(forKey: key), data2) + XCTAssertEqual(otherUserDefaults.data(forKey: key), data2) + } + + func testUserDefaultsDelete() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + standardUserDefaults.set(data1, forKey: key1) + standardUserDefaults.set(data2, forKey: key2) + + // When + standardStorage.delete(forKey: key1) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key1)) + XCTAssertEqual(standardUserDefaults.data(forKey: key2), data2) + + // When + standardStorage.delete(forKey: key1) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key1)) + XCTAssertEqual(standardUserDefaults.data(forKey: key2), data2) + + // When + standardStorage.delete(forKey: key2) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key1)) + XCTAssertNil(standardUserDefaults.data(forKey: key2)) + } + + func testUserDefaultsDeleteDifferentDomains() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + standardUserDefaults.set(data1, forKey: key) + otherUserDefaults.set(data2, forKey: key) + + // When + standardStorage.delete(forKey: key) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key)) + XCTAssertEqual(otherUserDefaults.data(forKey: key), data2) + + // When + standardStorage.delete(forKey: key) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key)) + XCTAssertEqual(otherUserDefaults.data(forKey: key), data2) + + // When + otherStorage.delete(forKey: key) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key)) + XCTAssertNil(otherUserDefaults.data(forKey: key)) + } + + func testUserDefaultsSet() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + standardUserDefaults.set(data1, forKey: key1) + standardUserDefaults.set(data2, forKey: key2) + + // When + standardStorage.set(data2, forKey: key1) + + // Then + XCTAssertEqual(standardUserDefaults.data(forKey: key1), data2) + XCTAssertEqual(standardUserDefaults.data(forKey: key2), data2) + + // When + standardStorage.set(nil, forKey: key1) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key1)) + XCTAssertEqual(standardUserDefaults.data(forKey: key2), data2) + + // When + standardStorage.set(data1, forKey: key2) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key1)) + XCTAssertEqual(standardUserDefaults.data(forKey: key2), data1) + + // When + standardStorage.set(nil, forKey: key2) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key1)) + XCTAssertNil(standardUserDefaults.data(forKey: key2)) + } + + func testUserDefaultsSetDifferentDomains() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key = "key" + standardUserDefaults.set(data1, forKey: key) + otherUserDefaults.set(data2, forKey: key) + + // When + standardStorage.set(data2, forKey: key) + + // Then + XCTAssertEqual(standardUserDefaults.data(forKey: key), data2) + XCTAssertEqual(otherUserDefaults.data(forKey: key), data2) + + // When + standardStorage.set(nil, forKey: key) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key)) + XCTAssertEqual(otherUserDefaults.data(forKey: key), data2) + + // When + otherStorage.set(data1, forKey: key) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key)) + XCTAssertEqual(otherUserDefaults.data(forKey: key), data1) + + // When + otherStorage.set(nil, forKey: key) + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key)) + XCTAssertNil(otherUserDefaults.data(forKey: key)) + } + + func testUserDefaultsClear() { + // Given + let data1 = Data([0xAA, 0xBB, 0xCC]) + let data2 = Data([0xDD, 0xEE, 0xFF]) + let key1 = "key1" + let key2 = "key2" + standardUserDefaults.set(data1, forKey: key1) + standardUserDefaults.set(data2, forKey: key2) + otherUserDefaults.set(data1, forKey: key1) + otherUserDefaults.set(data2, forKey: key2) + + // When + standardStorage.clear() + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key1)) + XCTAssertNil(standardUserDefaults.data(forKey: key2)) + XCTAssertEqual(otherUserDefaults.data(forKey: key1), data1) + XCTAssertEqual(otherUserDefaults.data(forKey: key2), data2) + + // When + standardStorage.clear() + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key1)) + XCTAssertNil(standardUserDefaults.data(forKey: key2)) + XCTAssertEqual(otherUserDefaults.data(forKey: key1), data1) + XCTAssertEqual(otherUserDefaults.data(forKey: key2), data2) + + // When + standardUserDefaults.set(data2, forKey: key1) + standardUserDefaults.set(data1, forKey: key2) + otherStorage.clear() + + // Then + XCTAssertEqual(standardUserDefaults.data(forKey: key1), data2) + XCTAssertEqual(standardUserDefaults.data(forKey: key2), data1) + XCTAssertNil(otherUserDefaults.data(forKey: key1)) + XCTAssertNil(otherUserDefaults.data(forKey: key2)) + + // When + standardStorage.clear() + + // Then + XCTAssertNil(standardUserDefaults.data(forKey: key1)) + XCTAssertNil(standardUserDefaults.data(forKey: key2)) + XCTAssertNil(otherUserDefaults.data(forKey: key1)) + XCTAssertNil(otherUserDefaults.data(forKey: key2)) + } + + func testInitCustomUserDefaults() { + // Given + let userDefaults = UserDefaults(suiteName: "mock")! + + // When + let storage = UserDefaultsStorage(userDefaults: userDefaults) + + // Then + XCTAssertNil(storage.domain) + } + + func testThreadSafety() { + // Given + let iterations = 5000 + let promise = expectation(description: "testThreadSafety") + let group = DispatchGroup() + for _ in 1...iterations { group.enter() } + + // When + DispatchQueue.concurrentPerform(iterations: iterations) { number in + let operation = Int.random(in: 0...4) + let key = "\(Int.random(in: 1000...9999))" + + Task.detached { + switch operation { + case 0: + _ = await self.standardStorage.fetch(forKey: key) + case 1: + await self.standardStorage.save(.init(), forKey: key) + case 2: + await self.standardStorage.delete(forKey: key) + case 3: + await self.standardStorage.set(Bool.random() ? nil : .init(), forKey: key) + case 4: + await self.standardStorage.clear() + default: + break + } + + group.leave() + } + } + + group.notify(queue: .main) { + promise.fulfill() + } + + wait(for: [promise], timeout: 5) + } +} + +extension UserDefaults { + func clearStandardStorage() { + self.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) + } +} + diff --git a/Tests/UnifiedStorageTests/KeyValueCodingStorageTests.swift b/Tests/UnifiedStorageTests/KeyValueCodingStorageTests.swift new file mode 100644 index 0000000..c26f823 --- /dev/null +++ b/Tests/UnifiedStorageTests/KeyValueCodingStorageTests.swift @@ -0,0 +1,291 @@ +// +// KeyValueCodingStorageTests.swift +// +// +// Created by Narek Sahakyan on 01.03.24. +// + +import XCTest +import Foundation +@testable import UnifiedStorage + +@InMemoryActor +final class KeyValueCodingStorageTests: XCTestCase { + var underlyingStorage: InMemoryStorage! + var storage: KeyValueCodingStorage! + var coder: DataCoder! + + override func setUp() async throws { + underlyingStorage = InMemoryStorage() + coder = JSONDataCoder() + + storage = .init(storage: underlyingStorage, coder: coder) + + InMemoryStorage.container = [nil: [:]] + } + + func testFetch() async throws { + // Given + let instance1 = CustomStruct(int: 3, string: "3", date: Date(timeIntervalSince1970: 3), inner: [Inner(id: .init())]) + let instance2 = CustomClass(int: 4, string: "4", date: Date(timeIntervalSince1970: 4), inner: [Inner(id: .init())]) + let instance3 = CustomActor(int: 5, string: "4", date: Date(timeIntervalSince1970: 5), inner: [Inner(id: .init())]) + let instance4 = CustomEnum.case1(6) + + let key1 = KeyValueCodingStorageKey(key: "key1") + let key2 = KeyValueCodingStorageKey(key: "key2") + let key3 = KeyValueCodingStorageKey(key: "key3") + let key4 = KeyValueCodingStorageKey(key: "key4") + + // When + var fetched1 = try await storage.fetch(forKey: key1) + var fetched2 = try await storage.fetch(forKey: key2) + var fetched3 = try await storage.fetch(forKey: key3) + var fetched4 = try await storage.fetch(forKey: key4) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + XCTAssertNil(fetched3) + XCTAssertNil(fetched4) + + // Given + InMemoryStorage.container = [nil: [ + "key1": try await coder.encode(instance1), + "key2": try await coder.encode(instance2), + "key3": try await coder.encode(instance3), + "key4": try await coder.encode(instance4) + ]] + + // When + fetched1 = try await storage.fetch(forKey: key1) + fetched2 = try await storage.fetch(forKey: key2) + fetched3 = try await storage.fetch(forKey: key3) + fetched4 = try await storage.fetch(forKey: key4) + + // Then + XCTAssertEqual(fetched1, instance1) + XCTAssertEqual(fetched2, instance2) + XCTAssertEqual(fetched3, instance3) + XCTAssertEqual(fetched4, instance4) + } + + func testSave() async throws { + // Given + let instance1 = CustomStruct(int: 3, string: "3", date: Date(timeIntervalSince1970: 3), inner: [Inner(id: .init())]) + let instance2 = CustomClass(int: 4, string: "4", date: Date(timeIntervalSince1970: 4), inner: [Inner(id: .init())]) + let instance3 = CustomActor(int: 5, string: "4", date: Date(timeIntervalSince1970: 5), inner: [Inner(id: .init())]) + let instance4 = CustomEnum.case1(6) + + let key1 = KeyValueCodingStorageKey(key: "key1") + let key2 = KeyValueCodingStorageKey(key: "key2") + let key3 = KeyValueCodingStorageKey(key: "key3") + let key4 = KeyValueCodingStorageKey(key: "key4") + + // When + try await storage.save(instance1, forKey: key1) + try await storage.save(instance2, forKey: key2) + try await storage.save(instance3, forKey: key3) + try await storage.save(instance4, forKey: key4) + + // Then + let decoded1: CustomStruct = try await coder.decode(InMemoryStorage.container[nil]!["key1"]!) + let decoded2: CustomClass = try await coder.decode(InMemoryStorage.container[nil]!["key2"]!) + let decoded3: CustomActor = try await coder.decode(InMemoryStorage.container[nil]!["key3"]!) + let decoded4: CustomEnum = try await coder.decode(InMemoryStorage.container[nil]!["key4"]!) + + XCTAssertEqual(decoded1, instance1) + XCTAssertEqual(decoded2, instance2) + XCTAssertEqual(decoded3, instance3) + XCTAssertEqual(decoded4, instance4) + } + + func testDelete() async throws { + // Given + let instance1 = CustomStruct(int: 3, string: "3", date: Date(timeIntervalSince1970: 3), inner: [Inner(id: .init())]) + let instance2 = CustomClass(int: 4, string: "4", date: Date(timeIntervalSince1970: 4), inner: [Inner(id: .init())]) + let instance3 = CustomActor(int: 5, string: "4", date: Date(timeIntervalSince1970: 5), inner: [Inner(id: .init())]) + let instance4 = CustomEnum.case1(6) + + let key1 = KeyValueCodingStorageKey(key: "key1") + let key2 = KeyValueCodingStorageKey(key: "key2") + let key3 = KeyValueCodingStorageKey(key: "key3") + let key4 = KeyValueCodingStorageKey(key: "key4") + + InMemoryStorage.container = [nil: [ + "key1": try await coder.encode(instance1), + "key2": try await coder.encode(instance2), + "key3": try await coder.encode(instance3), + "key4": try await coder.encode(instance4) + ]] + + // When + try await storage.delete(forKey: key1) + try await storage.delete(forKey: key2) + try await storage.delete(forKey: key3) + try await storage.delete(forKey: key4) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?["key1"]) + XCTAssertNil(InMemoryStorage.container[nil]?["key2"]) + XCTAssertNil(InMemoryStorage.container[nil]?["key3"]) + XCTAssertNil(InMemoryStorage.container[nil]?["key4"]) + } + + func testSet() async throws { + // Given + let instance1 = CustomStruct(int: 3, string: "3", date: Date(timeIntervalSince1970: 3), inner: [Inner(id: .init())]) + let instance2 = CustomClass(int: 4, string: "4", date: Date(timeIntervalSince1970: 4), inner: [Inner(id: .init())]) + let instance3 = CustomActor(int: 5, string: "4", date: Date(timeIntervalSince1970: 5), inner: [Inner(id: .init())]) + let instance4 = CustomEnum.case1(6) + + let key1 = KeyValueCodingStorageKey(key: "key1") + let key2 = KeyValueCodingStorageKey(key: "key2") + let key3 = KeyValueCodingStorageKey(key: "key3") + let key4 = KeyValueCodingStorageKey(key: "key4") + + InMemoryStorage.container = [nil: [ + "key1": try await coder.encode(instance1), + "key2": try await coder.encode(instance2), + "key3": try await coder.encode(instance3), + "key4": try await coder.encode(instance4) + ]] + + // When + try await storage.set(nil, forKey: key1) + try await storage.set(nil, forKey: key2) + try await storage.set(nil, forKey: key3) + try await storage.set(nil, forKey: key4) + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?["key1"]) + XCTAssertNil(InMemoryStorage.container[nil]?["key2"]) + XCTAssertNil(InMemoryStorage.container[nil]?["key3"]) + XCTAssertNil(InMemoryStorage.container[nil]?["key4"]) + + // When + try await storage.set(instance1, forKey: key1) + try await storage.set(instance2, forKey: key2) + try await storage.set(instance3, forKey: key3) + try await storage.set(instance4, forKey: key4) + + // Then + let decoded1: CustomStruct = try await coder.decode(InMemoryStorage.container[nil]!["key1"]!) + let decoded2: CustomClass = try await coder.decode(InMemoryStorage.container[nil]!["key2"]!) + let decoded3: CustomActor = try await coder.decode(InMemoryStorage.container[nil]!["key3"]!) + let decoded4: CustomEnum = try await coder.decode(InMemoryStorage.container[nil]!["key4"]!) + + XCTAssertEqual(decoded1, instance1) + XCTAssertEqual(decoded2, instance2) + XCTAssertEqual(decoded3, instance3) + XCTAssertEqual(decoded4, instance4) + } + + func testClear() async throws { + // Given + let instance1 = CustomStruct(int: 3, string: "3", date: Date(timeIntervalSince1970: 3), inner: [Inner(id: .init())]) + let instance2 = CustomClass(int: 4, string: "4", date: Date(timeIntervalSince1970: 4), inner: [Inner(id: .init())]) + let instance3 = CustomActor(int: 5, string: "4", date: Date(timeIntervalSince1970: 5), inner: [Inner(id: .init())]) + let instance4 = CustomEnum.case1(6) + + InMemoryStorage.container = [nil: [ + "key1": try await coder.encode(instance1), + "key2": try await coder.encode(instance2), + "key3": try await coder.encode(instance3), + "key4": try await coder.encode(instance4) + ]] + + // When + try await storage.clear() + + // Then + XCTAssertNil(InMemoryStorage.container[nil]?["key1"]) + XCTAssertNil(InMemoryStorage.container[nil]?["key2"]) + XCTAssertNil(InMemoryStorage.container[nil]?["key3"]) + XCTAssertNil(InMemoryStorage.container[nil]?["key4"]) + } + +} + +extension KeyValueCodingStorageTests { + struct CustomStruct: Codable, Equatable { + let int: Int + let string: String + let date: Date + let inner: [Inner] + } + + class CustomClass: Codable, Equatable { + let int: Int + let string: String + let date: Date + let inner: [Inner] + + init(int: Int, string: String, date: Date, inner: [Inner]) { + self.int = int + self.string = string + self.date = date + self.inner = inner + } + + static func == (lhs: CustomClass, rhs: CustomClass) -> Bool { + lhs.int == rhs.int && + lhs.string == rhs.string && + lhs.date == rhs.date && + lhs.inner == rhs.inner + } + } + + actor CustomActor: Codable, Equatable { + let int: Int + let string: String + let date: Date + let inner: [Inner] + + init(int: Int, string: String, date: Date, inner: [Inner]) { + self.int = int + self.string = string + self.date = date + self.inner = inner + } + + static func == (lhs: CustomActor, rhs: CustomActor) -> Bool { + lhs.int == rhs.int && + lhs.string == rhs.string && + lhs.date == rhs.date && + lhs.inner == rhs.inner + } + + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + self.int = try container.decode(Int.self, forKey: .int) + self.string = try container.decode(String.self, forKey: .string) + self.date = try container.decode(Date.self, forKey: .date) + self.inner = try container.decode([Inner].self, forKey: .inner) + } + + nonisolated func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.int, forKey: .int) + try container.encode(self.string, forKey: .string) + try container.encode(self.date, forKey: .date) + try container.encode(self.inner, forKey: .inner) + } + + enum CodingKeys: CodingKey { + case int + case string + case date + case inner + } + } + + enum CustomEnum: Codable, Equatable { + case case1(Int) + case case2(String) + case case3(date: Date) + } + + struct Inner: Codable, Equatable { + let id: UUID + } +} diff --git a/Tests/UnifiedStorageTests/KeyValueObservableStorageTests.swift b/Tests/UnifiedStorageTests/KeyValueObservableStorageTests.swift new file mode 100644 index 0000000..83e9fd9 --- /dev/null +++ b/Tests/UnifiedStorageTests/KeyValueObservableStorageTests.swift @@ -0,0 +1,324 @@ +// +// KeyValueObservableStorageTests.swift +// +// +// Created by Narek Sahakyan on 01.03.24. +// + +import XCTest +import Foundation +import Combine +@testable import UnifiedStorage + +@InMemoryActor +final class KeyValueObservableStorageTests: XCTestCase { + var underlyingStorage: InMemoryStorage! + var coder: DataCoder! + + override func setUp() async throws { + underlyingStorage = InMemoryStorage() + coder = JSONDataCoder() + InMemoryStorage.container = [nil: [:]] + } + + func testPublisherSameStorageSameDomainSameKey() async throws { + // Given + let operations = [ + ("save", 1), + ("save", 2), + ("delete", nil), + ("delete", nil), + ("set", 4), + ("set", nil), + ("save", 10), + ("clear", nil) + ] + let publisherExpectation = expectation(description: "testPublisherSave") + publisherExpectation.expectedFulfillmentCount = operations.count * 2 + + var operationIndex1 = 0 + var operationIndex2 = 0 + let storage = KeyValueObservableStorage(storage: InMemoryStorage(), coder: coder) + let key = KeyValueCodingStorageKey(key: "key") + var subscriptions = Set() + await storage.publisher(forKey: key).sink(receiveValue: { + // Then + XCTAssertEqual(operations[operationIndex1].1, $0) + operationIndex1 += 1 + publisherExpectation.fulfill() + }).store(in: &subscriptions) + await storage.publisher(forKey: key).sink(receiveValue: { + // Then + XCTAssertEqual(operations[operationIndex2].1, $0) + operationIndex2 += 1 + publisherExpectation.fulfill() + }).store(in: &subscriptions) + + // When + for operation in operations { + switch operation { + case let ("save", value): + try await storage.save(value!, forKey: key) + case let ("set", value): + try await storage.set(value, forKey: key) + case ("delete", _): + try await storage.delete(forKey: key) + case ("clear", _): + try await storage.clear() + default: + break + } + } + + await wait(to: [publisherExpectation], timeout: 1) + } + + func testPublisherDifferentStorageSameDomainSameKey1() async throws { + // Given + let operations = [ + ("save", 1, 1), + ("save", 2, 2), + ("delete", nil, 2), + ("delete", nil, 1), + ("set", 4, 2), + ("set", nil, 1), + ("save", 10, 1), + ("clear", nil, 1) + ] + let publisherExpectation = expectation(description: "testPublisherSave") + publisherExpectation.expectedFulfillmentCount = operations.count * 2 + + var operationIndex1 = 0 + var operationIndex2 = 0 + let storage1 = KeyValueObservableStorage(storage: InMemoryStorage(), coder: coder) + let storage2 = KeyValueObservableStorage(storage: InMemoryStorage(), coder: coder) + let key = KeyValueCodingStorageKey(key: "key") + var subscriptions = Set() + await storage1.publisher(forKey: key).sink(receiveValue: { + // Then + XCTAssertEqual(operations[operationIndex1].1, $0) + operationIndex1 += 1 + publisherExpectation.fulfill() + }).store(in: &subscriptions) + await storage2.publisher(forKey: key).sink(receiveValue: { + // Then + XCTAssertEqual(operations[operationIndex2].1, $0) + operationIndex2 += 1 + publisherExpectation.fulfill() + }).store(in: &subscriptions) + + // When + for operation in operations { + let storage = operation.2 == 1 ? storage1 : storage2 + switch operation { + case let ("save", value, _): + try await storage.save(value!, forKey: key) + case let ("set", value, _): + try await storage.set(value, forKey: key) + case ("delete", _ , _): + try await storage.delete(forKey: key) + case ("clear", _ , _): + try await storage.clear() + default: + break + } + } + + await wait(to: [publisherExpectation], timeout: 1) + } + + func testPublisherDifferentStorageSameDomainSameKey2() async throws { + // Given + let operations = [ + ("save", 1, 1), + ("save", 2, 2), + ("delete", nil, 2), + ("delete", nil, 1), + ("set", 4, 2), + ("set", nil, 1), + ("save", 10, 1), + ("clear", nil, 1) + ] + let publisherExpectation = expectation(description: "testPublisherSave") + publisherExpectation.expectedFulfillmentCount = operations.count * 2 + + var operationIndex1 = 0 + var operationIndex2 = 0 + let storage1 = KeyValueObservableStorage(storage: InMemoryStorage(domain: "x"), coder: coder) + let storage2 = KeyValueObservableStorage(storage: InMemoryStorage(domain: "x"), coder: coder) + let key = KeyValueCodingStorageKey(key: "key") + var subscriptions = Set() + await storage1.publisher(forKey: key).sink(receiveValue: { + // Then + XCTAssertEqual(operations[operationIndex1].1, $0) + operationIndex1 += 1 + publisherExpectation.fulfill() + }).store(in: &subscriptions) + await storage2.publisher(forKey: key).sink(receiveValue: { + // Then + XCTAssertEqual(operations[operationIndex2].1, $0) + operationIndex2 += 1 + publisherExpectation.fulfill() + }).store(in: &subscriptions) + + // When + for operation in operations { + let storage = operation.2 == 1 ? storage1 : storage2 + switch operation { + case let ("save", value, _): + try await storage.save(value!, forKey: key) + case let ("set", value, _): + try await storage.set(value, forKey: key) + case ("delete", _ , _): + try await storage.delete(forKey: key) + case ("clear", _ , _): + try await storage.clear() + default: + break + } + } + + await wait(to: [publisherExpectation], timeout: 1) + } + + func testDifferentDomainsSameKey() async throws { + // Given + let operations1 = [ + ("save", 1), + ("save", 2), + ("delete", nil), + ("delete", nil), + ("set", 4), + ("set", nil), + ("save", 10), + ("clear", nil) + ] + let operations2 = [ + ("delete", nil), + ("save", 6), + ("delete", nil), + ("set", 4), + ("save", 50), + ("clear", nil), + ("save", 32) + ] + let publisherExpectation = expectation(description: "testPublisherSave") + publisherExpectation.expectedFulfillmentCount = operations1.count + operations2.count + + var operationIndex1 = 0 + var operationIndex2 = 0 + let storage1 = KeyValueObservableStorage(storage: InMemoryStorage(), coder: coder) + let storage2 = KeyValueObservableStorage(storage: InMemoryStorage(domain: "other"), coder: coder) + let key = KeyValueCodingStorageKey(key: "key") + var subscriptions = Set() + await storage1.publisher(forKey: key).sink(receiveValue: { + // Then + XCTAssertEqual(operations1[operationIndex1].1, $0) + operationIndex1 += 1 + publisherExpectation.fulfill() + }).store(in: &subscriptions) + await storage2.publisher(forKey: key).sink(receiveValue: { + // Then + XCTAssertEqual(operations2[operationIndex2].1, $0) + operationIndex2 += 1 + publisherExpectation.fulfill() + }).store(in: &subscriptions) + + // When + for operation in operations1 { + switch operation { + case let ("save", value): + try await storage1.save(value!, forKey: key) + case let ("set", value): + try await storage1.set(value, forKey: key) + case ("delete", _): + try await storage1.delete(forKey: key) + case ("clear", _): + try await storage1.clear() + default: + break + } + } + + for operation in operations2 { + switch operation { + case let ("save", value): + try await storage2.save(value!, forKey: key) + case let ("set", value): + try await storage2.set(value, forKey: key) + case ("delete", _): + try await storage2.delete(forKey: key) + case ("clear", _): + try await storage2.clear() + default: + break + } + } + + await wait(to: [publisherExpectation], timeout: 1) + } + + func testAsyncStream() async throws { + // Given + let operations = [ + ("save", 1), + ("save", 2), + ("delete", nil), + ("delete", nil), + ("set", 4), + ("set", nil), + ("save", 10), + ("clear", nil) + ] + let publisherExpectation = expectation(description: "testPublisherSave") + publisherExpectation.expectedFulfillmentCount = operations.count + + let storage = KeyValueObservableStorage(storage: InMemoryStorage(), coder: coder) + let key = KeyValueCodingStorageKey(key: "key") + + let task = Task.detached { + var operationIndex = 0 + + for await change in await storage.stream(forKey: key) { + XCTAssertEqual(operations[operationIndex].1, change) + operationIndex += 1 + publisherExpectation.fulfill() + } + } + + // When + for operation in operations { + switch operation { + case let ("save", value): + print("save") + try await storage.save(value!, forKey: key) + case let ("set", value): + print("set") + try await storage.set(value, forKey: key) + case ("delete", _): + print("delete") + try await storage.delete(forKey: key) + case ("clear", _): + print("clear") + try await storage.clear() + default: + break + } + } + + await wait(to: [publisherExpectation], timeout: 1) + task.cancel() + } +} + +extension XCTestCase { + func wait(to expectations: [XCTestExpectation], timeout: TimeInterval) async { +#if swift(>=5.8) + await fulfillment(of: expectations, timeout: timeout) +#else + wait(for: expectations, timeout: timeout) +#endif + } +} + + diff --git a/Tests/UnifiedStorageTests/Mocks/FileManagerMock.swift b/Tests/UnifiedStorageTests/Mocks/FileManagerMock.swift new file mode 100644 index 0000000..44fc94c --- /dev/null +++ b/Tests/UnifiedStorageTests/Mocks/FileManagerMock.swift @@ -0,0 +1,34 @@ +// +// FileManagerMock.swift +// +// +// Created by Narek Sahakyan on 26.02.24. +// + +import Foundation + +final class FileManagerMock: FileManager { + var storage = [String: Data]() + var removeItemError: CocoaError? + var createDirectoryError: CocoaError? + var createFileError: CocoaError? + + override func removeItem(atPath path: String) throws { + if let removeItemError { + throw removeItemError + } + + storage[path] = nil + } + + override func createDirectory(atPath path: String, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { + if let createDirectoryError { + throw createDirectoryError + } + } + + override func createFile(atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey : Any]? = nil) -> Bool { + storage[path] = data + return createFileError == nil + } +} diff --git a/Tests/UnifiedStorageTests/Mocks/InMemoryMock.swift b/Tests/UnifiedStorageTests/Mocks/InMemoryMock.swift new file mode 100644 index 0000000..d766050 --- /dev/null +++ b/Tests/UnifiedStorageTests/Mocks/InMemoryMock.swift @@ -0,0 +1,36 @@ +// +// File.swift +// +// +// Created by Narek Sahakyan on 02.03.24. +// + +import Foundation +@testable import UnifiedStorage + +final class InMemoryMock: InMemoryStorage { + private(set) var saveCalled = false + private(set) var fetchCalled = false + private(set) var deleteCalled = false + private(set) var clearCalled = false + + override func save(_ value: Data, forKey key: InMemoryStorage.Key) { + saveCalled = true + super.save(value, forKey: key) + } + + override func fetch(forKey key: InMemoryStorage.Key) -> Data? { + fetchCalled = true + return super.fetch(forKey: key) + } + + override func delete(forKey key: InMemoryStorage.Key) { + deleteCalled = true + super.delete(forKey: key) + } + + override func clear() { + clearCalled = true + super.clear() + } +} diff --git a/Tests/UnifiedStorageTests/Mocks/KeychainHelperMock.swift b/Tests/UnifiedStorageTests/Mocks/KeychainHelperMock.swift new file mode 100644 index 0000000..182b55f --- /dev/null +++ b/Tests/UnifiedStorageTests/Mocks/KeychainHelperMock.swift @@ -0,0 +1,49 @@ +// +// KeychainMock.swift +// +// +// Created by Narek Sahakyan on 26.02.24. +// + +import Foundation +@testable import UnifiedStorage + +final class KeychainHelperMock: KeychainHelper { + var storage = [String: Data]() + var getError: Error? + var setError: KeychainHelperError? + var removeError: KeychainHelperError? + var removeAllError: KeychainHelperError? + + override func get(forKey key: String, withAccessibility accessibility: KeychainAccessibility? = nil, isSynchronizable: Bool = false) throws -> Data? { + if let getError { + throw getError + } + + return storage[key] + } + + override func set(_ value: Data, forKey key: String, withAccessibility accessibility: KeychainAccessibility? = nil, isSynchronizable: Bool = false) throws { + if let setError { + throw setError + } + + return storage[key] = value + } + + override func remove(forKey key: String, withAccessibility accessibility: KeychainAccessibility? = nil, isSynchronizable: Bool = false) throws { + if let removeError { + throw removeError + } + + storage[key] = nil + } + + override func removeAll() throws { + if let removeAllError { + throw removeAllError + } + + storage.removeAll() + } +} diff --git a/Tests/UnifiedStorageTests/Mocks/UserDefaultsMock.swift b/Tests/UnifiedStorageTests/Mocks/UserDefaultsMock.swift new file mode 100644 index 0000000..ebf5d34 --- /dev/null +++ b/Tests/UnifiedStorageTests/Mocks/UserDefaultsMock.swift @@ -0,0 +1,29 @@ +// +// UserDefaultsMock.swift +// +// +// Created by Narek Sahakyan on 02.03.24. +// + +import Foundation +@testable import UnifiedStorage + +final class UserDefaultsMock: UserDefaults { + var storage = [String: Data]() + override func data(forKey defaultName: String) -> Data? { + storage[defaultName] + } + + override func set(_ value: Any?, forKey defaultName: String) { + storage[defaultName] = value as? Data + } + + override func removeObject(forKey defaultName: String) { + storage[defaultName] = nil + } + + override func removePersistentDomain(forName domainName: String) { + storage.removeAll() + } +} + diff --git a/Tests/UnifiedStorageTests/UnifiedStorageTests.swift b/Tests/UnifiedStorageTests/UnifiedStorageTests.swift new file mode 100644 index 0000000..3bb4171 --- /dev/null +++ b/Tests/UnifiedStorageTests/UnifiedStorageTests.swift @@ -0,0 +1,306 @@ +// +// UnifiedStorageTests.swift +// +// +// Created by Narek Sahakyan on 12.12.23. +// + +import XCTest +import Foundation +@testable import UnifiedStorage + +final class UnifiedStorageTests: XCTestCase { + + override func setUp() async throws { + await InMemoryStorage().clear() + await InMemoryStorage(domain: "other").clear() + + await UserDefaultsStorage().clear() + try await UserDefaultsStorage(domain: "other").clear() + } + + func testStorage() async throws { + // Given + let key1 = InMemoryKey(key: "key1") + let key2 = InMemoryKey(key: "key2") + let key3 = InMemoryKey(key: "key3", domain: "other") + let key4 = InMemoryKey(key: "key4", domain: "other") + let key5 = UserDefaultsKey(key: "key1") + let key6 = UserDefaultsKey(key: "key2") + let key7 = UserDefaultsKey(key: "key3", domain: "other") + let key8 = UserDefaultsKey(key: "key4", domain: "other") + let storage = UnifiedStorage() + + // When + var fetched1 = try await storage.fetch(forKey: key1) + var fetched2 = try await storage.fetch(forKey: key2) + var fetched3 = try await storage.fetch(forKey: key3) + var fetched4 = try await storage.fetch(forKey: key4) + var fetched5 = try await storage.fetch(forKey: key5) + var fetched6 = try await storage.fetch(forKey: key6) + var fetched7 = try await storage.fetch(forKey: key7) + var fetched8 = try await storage.fetch(forKey: key8) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + XCTAssertNil(fetched3) + XCTAssertNil(fetched4) + XCTAssertNil(fetched5) + XCTAssertNil(fetched6) + XCTAssertNil(fetched7) + XCTAssertNil(fetched8) + + // When + try await storage.save("data1", forKey: key1) + try await storage.save("data2", forKey: key2) + try await storage.save("data3", forKey: key3) + try await storage.save("data4", forKey: key4) + try await storage.save("data5", forKey: key5) + try await storage.save("data6", forKey: key6) + try await storage.save("data7", forKey: key7) + try await storage.save("data8", forKey: key8) + + fetched1 = try await storage.fetch(forKey: key1) + fetched2 = try await storage.fetch(forKey: key2) + fetched3 = try await storage.fetch(forKey: key3) + fetched4 = try await storage.fetch(forKey: key4) + fetched5 = try await storage.fetch(forKey: key5) + fetched6 = try await storage.fetch(forKey: key6) + fetched7 = try await storage.fetch(forKey: key7) + fetched8 = try await storage.fetch(forKey: key8) + + // Then + XCTAssertEqual(fetched1, "data1") + XCTAssertEqual(fetched2, "data2") + XCTAssertEqual(fetched3, "data3") + XCTAssertEqual(fetched4, "data4") + XCTAssertEqual(fetched5, "data5") + XCTAssertEqual(fetched6, "data6") + XCTAssertEqual(fetched7, "data7") + XCTAssertEqual(fetched8, "data8") + + // When + try await storage.delete(forKey: key1) + try await storage.delete(forKey: key2) + try await storage.delete(forKey: key3) + try await storage.delete(forKey: key4) + try await storage.delete(forKey: key5) + try await storage.delete(forKey: key6) + try await storage.delete(forKey: key7) + try await storage.delete(forKey: key8) + + fetched1 = try await storage.fetch(forKey: key1) + fetched2 = try await storage.fetch(forKey: key2) + fetched3 = try await storage.fetch(forKey: key3) + fetched4 = try await storage.fetch(forKey: key4) + fetched5 = try await storage.fetch(forKey: key5) + fetched6 = try await storage.fetch(forKey: key6) + fetched7 = try await storage.fetch(forKey: key7) + fetched8 = try await storage.fetch(forKey: key8) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + XCTAssertNil(fetched3) + XCTAssertNil(fetched4) + XCTAssertNil(fetched5) + XCTAssertNil(fetched6) + XCTAssertNil(fetched7) + XCTAssertNil(fetched8) + + // When + try await storage.set("newData1", forKey: key1) + try await storage.set("newData2", forKey: key2) + try await storage.set("newData3", forKey: key3) + try await storage.set("newData4", forKey: key4) + try await storage.set("newData5", forKey: key5) + try await storage.set("newData6", forKey: key6) + try await storage.set("newData7", forKey: key7) + try await storage.set("newData8", forKey: key8) + + fetched1 = try await storage.fetch(forKey: key1) + fetched2 = try await storage.fetch(forKey: key2) + fetched3 = try await storage.fetch(forKey: key3) + fetched4 = try await storage.fetch(forKey: key4) + fetched5 = try await storage.fetch(forKey: key5) + fetched6 = try await storage.fetch(forKey: key6) + fetched7 = try await storage.fetch(forKey: key7) + fetched8 = try await storage.fetch(forKey: key8) + + // Then + XCTAssertEqual(fetched1, "newData1") + XCTAssertEqual(fetched2, "newData2") + XCTAssertEqual(fetched3, "newData3") + XCTAssertEqual(fetched4, "newData4") + XCTAssertEqual(fetched5, "newData5") + XCTAssertEqual(fetched6, "newData6") + XCTAssertEqual(fetched7, "newData7") + XCTAssertEqual(fetched8, "newData8") + + // When + try await storage.set(nil, forKey: key1) + try await storage.set(nil, forKey: key2) + try await storage.set(nil, forKey: key3) + try await storage.set(nil, forKey: key4) + try await storage.set(nil, forKey: key5) + try await storage.set(nil, forKey: key6) + try await storage.set(nil, forKey: key7) + try await storage.set(nil, forKey: key8) + + fetched1 = try await storage.fetch(forKey: key1) + fetched2 = try await storage.fetch(forKey: key2) + fetched3 = try await storage.fetch(forKey: key3) + fetched4 = try await storage.fetch(forKey: key4) + fetched5 = try await storage.fetch(forKey: key5) + fetched6 = try await storage.fetch(forKey: key6) + fetched7 = try await storage.fetch(forKey: key7) + fetched8 = try await storage.fetch(forKey: key8) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + XCTAssertNil(fetched3) + XCTAssertNil(fetched4) + XCTAssertNil(fetched5) + XCTAssertNil(fetched6) + XCTAssertNil(fetched7) + XCTAssertNil(fetched8) + + // When + try await storage.save("newData1", forKey: key1) + try await storage.save("newData2", forKey: key2) + try await storage.save("newData3", forKey: key3) + try await storage.save("newData4", forKey: key4) + try await storage.save("newData5", forKey: key5) + try await storage.save("newData6", forKey: key6) + try await storage.save("newData7", forKey: key7) + try await storage.save("newData8", forKey: key8) + + try await storage.clear(storage: KeychainStorage.self) + + fetched1 = try await storage.fetch(forKey: key1) + fetched2 = try await storage.fetch(forKey: key2) + fetched3 = try await storage.fetch(forKey: key3) + fetched4 = try await storage.fetch(forKey: key4) + fetched5 = try await storage.fetch(forKey: key5) + fetched6 = try await storage.fetch(forKey: key6) + fetched7 = try await storage.fetch(forKey: key7) + fetched8 = try await storage.fetch(forKey: key8) + + // Then + XCTAssertEqual(fetched1, "newData1") + XCTAssertEqual(fetched2, "newData2") + XCTAssertEqual(fetched3, "newData3") + XCTAssertEqual(fetched4, "newData4") + XCTAssertEqual(fetched5, "newData5") + XCTAssertEqual(fetched6, "newData6") + XCTAssertEqual(fetched7, "newData7") + XCTAssertEqual(fetched8, "newData8") + + // When + try await storage.clear(storage: InMemoryStorage.self, forDomain: nil) + + fetched1 = try await storage.fetch(forKey: key1) + fetched2 = try await storage.fetch(forKey: key2) + fetched3 = try await storage.fetch(forKey: key3) + fetched4 = try await storage.fetch(forKey: key4) + fetched5 = try await storage.fetch(forKey: key5) + fetched6 = try await storage.fetch(forKey: key6) + fetched7 = try await storage.fetch(forKey: key7) + fetched8 = try await storage.fetch(forKey: key8) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + XCTAssertEqual(fetched3, "newData3") + XCTAssertEqual(fetched4, "newData4") + XCTAssertEqual(fetched5, "newData5") + XCTAssertEqual(fetched6, "newData6") + XCTAssertEqual(fetched7, "newData7") + XCTAssertEqual(fetched8, "newData8") + + // When + try await storage.clear(storage: InMemoryStorage.self) + + fetched1 = try await storage.fetch(forKey: key1) + fetched2 = try await storage.fetch(forKey: key2) + fetched3 = try await storage.fetch(forKey: key3) + fetched4 = try await storage.fetch(forKey: key4) + fetched5 = try await storage.fetch(forKey: key5) + fetched6 = try await storage.fetch(forKey: key6) + fetched7 = try await storage.fetch(forKey: key7) + fetched8 = try await storage.fetch(forKey: key8) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + XCTAssertNil(fetched3) + XCTAssertNil(fetched4) + XCTAssertEqual(fetched5, "newData5") + XCTAssertEqual(fetched6, "newData6") + XCTAssertEqual(fetched7, "newData7") + XCTAssertEqual(fetched8, "newData8") + + // When + try await storage.clear() + + fetched1 = try await storage.fetch(forKey: key1) + fetched2 = try await storage.fetch(forKey: key2) + fetched3 = try await storage.fetch(forKey: key3) + fetched4 = try await storage.fetch(forKey: key4) + fetched5 = try await storage.fetch(forKey: key5) + fetched6 = try await storage.fetch(forKey: key6) + fetched7 = try await storage.fetch(forKey: key7) + fetched8 = try await storage.fetch(forKey: key8) + + // Then + XCTAssertNil(fetched1) + XCTAssertNil(fetched2) + XCTAssertNil(fetched3) + XCTAssertNil(fetched4) + XCTAssertNil(fetched5) + XCTAssertNil(fetched6) + XCTAssertNil(fetched7) + XCTAssertNil(fetched8) + } + + func testNonObservable() async throws { + // Given + var stoarge = UnifiedStorage(factory: DefaultUnifiedStorageFactory()) + + // When + var publisher = try await stoarge.publisher(forKey: InMemoryKey(key: "key")) + var stream = try await stoarge.stream(forKey: InMemoryKey(key: "key")) + + // Then + XCTAssertNil(publisher) + XCTAssertNil(stream) + + // Given + stoarge = UnifiedStorage(factory: ObservableUnifiedStorageFactory()) + + // When + publisher = try await stoarge.publisher(forKey: InMemoryKey(key: "key")) + stream = try await stoarge.stream(forKey: InMemoryKey(key: "key")) + + // Then + XCTAssertNotNil(publisher) + XCTAssertNotNil(stream) + } +} + +final class MockedUnifiedStorageFactory: DefaultUnifiedStorageFactory { + override func dataStorage(for domain: Storage.Domain?) async throws -> Storage { + switch Storage.self { + case is InMemoryMock.Type: + if let domain = domain as? InMemoryStorage.Domain { + return await InMemoryMock(domain: domain) as! Storage + } else { + return await InMemoryMock() as! Storage + } + default: + return try await super.dataStorage(for: domain) + } + } +}