From f6e6f6991f243b43491c929ed12632fa6d859380 Mon Sep 17 00:00:00 2001 From: Lukas Korba Date: Tue, 3 May 2022 14:34:17 +0200 Subject: [PATCH] [#212] Wrapped user defaults (#298) tests enhanced guard/fail cleanup --- secant.xcodeproj/project.pbxproj | 4 + secant/Util/UserPreferencesStorage.swift | 103 ++++++----- secant/Wrappers/WrappedUserDefaults.swift | 44 +++++ .../UserPreferencesStorageTests.swift | 171 +++++++++++++++++- 4 files changed, 278 insertions(+), 44 deletions(-) create mode 100644 secant/Wrappers/WrappedUserDefaults.swift diff --git a/secant.xcodeproj/project.pbxproj b/secant.xcodeproj/project.pbxproj index 6950f44..12234e4 100644 --- a/secant.xcodeproj/project.pbxproj +++ b/secant.xcodeproj/project.pbxproj @@ -105,6 +105,7 @@ 9E5BF63F2819542C00BA3F17 /* TransactionHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF63E2819542C00BA3F17 /* TransactionHistoryTests.swift */; }; 9E5BF641281FD7B600BA3F17 /* TransactionFailedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF640281FD7B600BA3F17 /* TransactionFailedView.swift */; }; 9E5BF644281FEC9900BA3F17 /* SendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF643281FEC9900BA3F17 /* SendTests.swift */; }; + 9E5BF6462821028C00BA3F17 /* WrappedUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF6452821028C00BA3F17 /* WrappedUserDefaults.swift */; }; 9E69A24D27FB002800A55317 /* Welcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E69A24C27FB002800A55317 /* Welcome.swift */; }; 9E80B47227E4B34B008FF493 /* UserPreferencesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */; }; 9EAFEB822805793200199FC9 /* AppReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAFEB812805793200199FC9 /* AppReducerTests.swift */; }; @@ -272,6 +273,7 @@ 9E5BF63E2819542C00BA3F17 /* TransactionHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionHistoryTests.swift; sourceTree = ""; }; 9E5BF640281FD7B600BA3F17 /* TransactionFailedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionFailedView.swift; sourceTree = ""; }; 9E5BF643281FEC9900BA3F17 /* SendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendTests.swift; sourceTree = ""; }; + 9E5BF6452821028C00BA3F17 /* WrappedUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedUserDefaults.swift; sourceTree = ""; }; 9E69A24C27FB002800A55317 /* Welcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Welcome.swift; sourceTree = ""; }; 9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferencesStorage.swift; sourceTree = ""; }; 9EAFEB812805793200199FC9 /* AppReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReducerTests.swift; sourceTree = ""; }; @@ -759,6 +761,7 @@ 9EAFEB83280597B700199FC9 /* WrappedSecItem.swift */, 9E02B5C2280458D2005B809B /* WrappedDerivationTool.swift */, 9EAFEB872806E5AE00199FC9 /* WrappedSDKSynchronizer.swift */, + 9E5BF6452821028C00BA3F17 /* WrappedUserDefaults.swift */, ); path = Wrappers; sourceTree = ""; @@ -1288,6 +1291,7 @@ 9EAFEB84280597B700199FC9 /* WrappedSecItem.swift in Sources */, 9E2AC10327DA28200042AA47 /* WalletStorage.swift in Sources */, 9ECAE56827FC713C0089A0EF /* DatabaseFiles.swift in Sources */, + 9E5BF6462821028C00BA3F17 /* WrappedUserDefaults.swift in Sources */, F9971A6B27680E1000A2DB75 /* WalletInfo.swift in Sources */, 0D185819272723FF0046B928 /* ColoredChip.swift in Sources */, 2EA11F5D27467F7700709571 /* OnboardingContentView.swift in Sources */, diff --git a/secant/Util/UserPreferencesStorage.swift b/secant/Util/UserPreferencesStorage.swift index 480ef70..d3d2198 100644 --- a/secant/Util/UserPreferencesStorage.swift +++ b/secant/Util/UserPreferencesStorage.swift @@ -6,25 +6,12 @@ // import Foundation - -/// Representation of the user preferences stored in the local persistent storage (non-encrypted, no security needed) -protocol UserPreferences { - /// From when the app is on and uninterrupted - var activeAppSessionFrom: TimeInterval { get set } - /// What is the set up currency - var currency: String { get set } - /// Whether the fiat conversion is on/off - var isFiatConverted: Bool { get set } - /// Whether user finished recovery phrase backup test - var isRecoveryPhraseTestCompleted: Bool { get set } - /// Whether the user has been autoshielded in the running session - var isSessionAutoshielded: Bool { get set } -} +import ComposableArchitecture /// Live implementation of the `UserPreferences` using User Defaults /// according to https://developer.apple.com/documentation/foundation/userdefaults /// the UserDefaults class is thread-safe. -struct UserPreferencesStorage: UserPreferences { +struct UserPreferencesStorage { enum Constants: String, CaseIterable { case zcashActiveAppSessionFrom case zcashCurrency @@ -32,15 +19,6 @@ struct UserPreferencesStorage: UserPreferences { case zcashRecoveryPhraseTestCompleted case zcashSessionAutoshielded } - - static let `default` = UserPreferencesStorage( - appSessionFrom: Date().timeIntervalSince1970, - convertedCurrency: "USD", - fiatConvertion: true, - recoveryPhraseTestCompleted: false, - sessionAutoshielded: true, - userDefaults: UserDefaults.standard - ) /// Default values for all preferences in case there is no value stored (counterparts to `Constants`) private let appSessionFrom: TimeInterval @@ -49,7 +27,7 @@ struct UserPreferencesStorage: UserPreferences { private let recoveryPhraseTestCompleted: Bool private let sessionAutoshielded: Bool - private let userDefaults: UserDefaults + private let userDefaults: WrappedUserDefaults init( appSessionFrom: TimeInterval, @@ -57,7 +35,7 @@ struct UserPreferencesStorage: UserPreferences { fiatConvertion: Bool, recoveryPhraseTestCompleted: Bool, sessionAutoshielded: Bool, - userDefaults: UserDefaults + userDefaults: WrappedUserDefaults ) { self.appSessionFrom = appSessionFrom self.convertedCurrency = convertedCurrency @@ -69,47 +47,88 @@ struct UserPreferencesStorage: UserPreferences { /// From when the app is on and uninterrupted var activeAppSessionFrom: TimeInterval { - get { getValue(forKey: Constants.zcashActiveAppSessionFrom.rawValue, default: appSessionFrom) } - set { setValue(newValue, forKey: Constants.zcashActiveAppSessionFrom.rawValue) } + getValue(forKey: Constants.zcashActiveAppSessionFrom.rawValue, default: appSessionFrom) + } + + func setActiveAppSessionFrom(_ timeInterval: TimeInterval) -> Effect { + setValue(timeInterval, forKey: Constants.zcashActiveAppSessionFrom.rawValue) } /// What is the set up currency var currency: String { - get { getValue(forKey: Constants.zcashCurrency.rawValue, default: convertedCurrency) } - set { setValue(newValue, forKey: Constants.zcashCurrency.rawValue) } + getValue(forKey: Constants.zcashCurrency.rawValue, default: convertedCurrency) + } + + func setCurrency(_ string: String) -> Effect { + setValue(string, forKey: Constants.zcashCurrency.rawValue) } /// Whether the fiat conversion is on/off var isFiatConverted: Bool { - get { getValue(forKey: Constants.zcashFiatConverted.rawValue, default: fiatConvertion) } - set { setValue(newValue, forKey: Constants.zcashFiatConverted.rawValue) } + getValue(forKey: Constants.zcashFiatConverted.rawValue, default: fiatConvertion) + } + + func setIsFiatConverted(_ bool: Bool) -> Effect { + setValue(bool, forKey: Constants.zcashFiatConverted.rawValue) } /// Whether user finished recovery phrase backup test var isRecoveryPhraseTestCompleted: Bool { - get { getValue(forKey: Constants.zcashRecoveryPhraseTestCompleted.rawValue, default: recoveryPhraseTestCompleted) } - set { setValue(newValue, forKey: Constants.zcashRecoveryPhraseTestCompleted.rawValue) } + getValue(forKey: Constants.zcashRecoveryPhraseTestCompleted.rawValue, default: recoveryPhraseTestCompleted) + } + + func setIsRecoveryPhraseTestCompleted(_ bool: Bool) -> Effect { + setValue(bool, forKey: Constants.zcashRecoveryPhraseTestCompleted.rawValue) } /// Whether the user has been autoshielded in the running session var isSessionAutoshielded: Bool { - get { getValue(forKey: Constants.zcashSessionAutoshielded.rawValue, default: sessionAutoshielded) } - set { setValue(newValue, forKey: Constants.zcashSessionAutoshielded.rawValue) } + getValue(forKey: Constants.zcashSessionAutoshielded.rawValue, default: sessionAutoshielded) + } + + func setIsSessionAutoshielded(_ bool: Bool) -> Effect { + setValue(bool, forKey: Constants.zcashSessionAutoshielded.rawValue) } /// Use carefully: Deletes all user preferences from the User Defaults - func removeAll() { - Constants.allCases.forEach { userDefaults.removeObject(forKey: $0.rawValue) } + func removeAll() -> Effect { + var removals: [Effect] = [] + + Constants.allCases.forEach { removals.append(userDefaults.remove($0.rawValue)) } + + return Effect.concatenate(removals) } } private extension UserPreferencesStorage { func getValue(forKey: String, default defaultIfNil: Value) -> Value { - userDefaults.object(forKey: forKey) as? Value ?? defaultIfNil + userDefaults.objectForKey(forKey) as? Value ?? defaultIfNil } - func setValue(_ value: Value, forKey: String) { - userDefaults.set(value, forKey: forKey) - userDefaults.synchronize() + func setValue(_ value: Value, forKey: String) -> Effect { + let effect = userDefaults.setValue(value, forKey) + _ = userDefaults.synchronize() + + return effect } } + +extension UserPreferencesStorage { + static let live = UserPreferencesStorage( + appSessionFrom: Date().timeIntervalSince1970, + convertedCurrency: "USD", + fiatConvertion: true, + recoveryPhraseTestCompleted: false, + sessionAutoshielded: true, + userDefaults: .live() + ) + + static let mock = UserPreferencesStorage( + appSessionFrom: 1651039606.0, + convertedCurrency: "USD", + fiatConvertion: true, + recoveryPhraseTestCompleted: false, + sessionAutoshielded: true, + userDefaults: .mock + ) +} diff --git a/secant/Wrappers/WrappedUserDefaults.swift b/secant/Wrappers/WrappedUserDefaults.swift new file mode 100644 index 0000000..76103b4 --- /dev/null +++ b/secant/Wrappers/WrappedUserDefaults.swift @@ -0,0 +1,44 @@ +// +// WrappedUserDefaults.swift +// secant-testnet +// +// Created by Lukáš Korba on 03.05.2022. +// + +import Foundation +import ComposableArchitecture + +struct WrappedUserDefaults { + let objectForKey: (String) -> Any? + let remove: (String) -> Effect + let setValue: (Any?, String) -> Effect + let synchronize: () -> Bool +} + +extension WrappedUserDefaults { + static func live( + userDefaults: UserDefaults = .standard + ) -> Self { + Self( + objectForKey: userDefaults.object(forKey:), + remove: { key in + .fireAndForget { + userDefaults.removeObject(forKey: key) + } + }, + setValue: { value, key in + .fireAndForget { + userDefaults.set(value, forKey: key) + } + }, + synchronize: userDefaults.synchronize + ) + } + + static let mock = WrappedUserDefaults( + objectForKey: { _ in }, + remove: { _ in .none }, + setValue: { _, _ in .none }, + synchronize: { true } + ) +} diff --git a/secantTests/UtilTests/UserPreferencesStorageTests.swift b/secantTests/UtilTests/UserPreferencesStorageTests.swift index e874d6e..95c250d 100644 --- a/secantTests/UtilTests/UserPreferencesStorageTests.swift +++ b/secantTests/UtilTests/UserPreferencesStorageTests.swift @@ -7,29 +7,45 @@ import XCTest @testable import secant_testnet +import Combine class UserPreferencesStorageTests: XCTestCase { + private var cancellables: [AnyCancellable] = [] + // swiftlint:disable:next implicitly_unwrapped_optional var storage: UserPreferencesStorage! override func setUp() { super.setUp() + + guard let userDefaults = UserDefaults.init(suiteName: "test") else { + XCTFail("UserPreferencesStorageTests: UserDefaults.init(suiteName: "test") failed to initialize") + return + } + storage = UserPreferencesStorage( appSessionFrom: 12345678.0, convertedCurrency: "USD", fiatConvertion: true, recoveryPhraseTestCompleted: true, sessionAutoshielded: false, - userDefaults: .standard + userDefaults: .live(userDefaults: userDefaults) ) storage.removeAll() + .sink(receiveValue: { _ in }) + .store(in: &cancellables) } override func tearDown() { super.tearDown() + storage.removeAll() + .sink(receiveValue: { _ in }) + .store(in: &cancellables) storage = nil } + // MARK: - Default values in the live UserDefaults environment + func testAppSessionFrom_defaultValue() throws { XCTAssertEqual(12345678.0, storage.activeAppSessionFrom, "User Preferences: `activeAppSessionFrom` default doesn't match.") } @@ -50,8 +66,157 @@ class UserPreferencesStorageTests: XCTestCase { XCTAssertEqual(false, storage.isSessionAutoshielded, "User Preferences: `isSessionAutoshielded` default doesn't match.") } + // MARK: - Set new values in the live UserDefaults environment + + func testAppSessionFrom_setNewValue() throws { + storage.setActiveAppSessionFrom(87654321.0) + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + XCTAssertEqual(87654321.0, storage.activeAppSessionFrom, "User Preferences: `activeAppSessionFrom` default doesn't match.") + } + + func testConvertedCurrency_setNewValue() throws { + storage.setCurrency("CZK") + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + XCTAssertEqual("CZK", storage.currency, "User Preferences: `currency` default doesn't match.") + } + + func testFiatConvertion_setNewValue() throws { + storage.setIsFiatConverted(false) + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + XCTAssertEqual(false, storage.isFiatConverted, "User Preferences: `isFiatConverted` default doesn't match.") + } + + func testRecoveryPhraseTestCompleted_setNewValue() throws { + storage.setIsRecoveryPhraseTestCompleted(false) + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + XCTAssertEqual(false, storage.isRecoveryPhraseTestCompleted, "User Preferences: `isRecoveryPhraseTestCompleted` default doesn't match.") + } + + func testSessionAutoshielded_setNewValue() throws { + storage.setIsSessionAutoshielded(true) + .sink(receiveValue: { _ in }) + .store(in: &cancellables) + + XCTAssertEqual(true, storage.isSessionAutoshielded, "User Preferences: `isSessionAutoshielded` default doesn't match.") + } + + // MARK: - Mocked user defaults vs. default values + + func testAppSessionFrom_mocked() throws { + let mockedUD = WrappedUserDefaults( + objectForKey: { _ in 87654321.0 }, + remove: { _ in .none }, + setValue: { _, _ in .none }, + synchronize: { true } + ) + + let mockedStorage = UserPreferencesStorage( + appSessionFrom: 12345678.0, + convertedCurrency: "USD", + fiatConvertion: true, + recoveryPhraseTestCompleted: true, + sessionAutoshielded: false, + userDefaults: mockedUD + ) + + XCTAssertEqual(87654321.0, mockedStorage.activeAppSessionFrom, "User Preferences: `activeAppSessionFrom` default doesn't match.") + } + + func testConvertedCurrency_mocked() throws { + let mockedUD = WrappedUserDefaults( + objectForKey: { _ in "CZK" }, + remove: { _ in .none }, + setValue: { _, _ in .none }, + synchronize: { true } + ) + + let mockedStorage = UserPreferencesStorage( + appSessionFrom: 12345678.0, + convertedCurrency: "USD", + fiatConvertion: true, + recoveryPhraseTestCompleted: true, + sessionAutoshielded: false, + userDefaults: mockedUD + ) + + XCTAssertEqual("CZK", mockedStorage.currency, "User Preferences: `currency` default doesn't match.") + } + + func testFiatConvertion_mocked() throws { + let mockedUD = WrappedUserDefaults( + objectForKey: { _ in false }, + remove: { _ in .none }, + setValue: { _, _ in .none }, + synchronize: { true } + ) + + let mockedStorage = UserPreferencesStorage( + appSessionFrom: 12345678.0, + convertedCurrency: "USD", + fiatConvertion: true, + recoveryPhraseTestCompleted: true, + sessionAutoshielded: false, + userDefaults: mockedUD + ) + + XCTAssertEqual(false, mockedStorage.isFiatConverted, "User Preferences: `isFiatConverted` default doesn't match.") + } + + func testRecoveryPhraseTestCompleted_mocked() throws { + let mockedUD = WrappedUserDefaults( + objectForKey: { _ in false }, + remove: { _ in .none }, + setValue: { _, _ in .none }, + synchronize: { true } + ) + + let mockedStorage = UserPreferencesStorage( + appSessionFrom: 12345678.0, + convertedCurrency: "USD", + fiatConvertion: true, + recoveryPhraseTestCompleted: true, + sessionAutoshielded: false, + userDefaults: mockedUD + ) + + XCTAssertEqual(false, mockedStorage.isRecoveryPhraseTestCompleted, "User Preferences: `isRecoveryPhraseTestCompleted` default doesn't match.") + } + + func testSessionAutoshielded_mocked() throws { + let mockedUD = WrappedUserDefaults( + objectForKey: { _ in true }, + remove: { _ in .none }, + setValue: { _, _ in .none }, + synchronize: { true } + ) + + let mockedStorage = UserPreferencesStorage( + appSessionFrom: 12345678.0, + convertedCurrency: "USD", + fiatConvertion: true, + recoveryPhraseTestCompleted: true, + sessionAutoshielded: false, + userDefaults: mockedUD + ) + + XCTAssertEqual(true, mockedStorage.isSessionAutoshielded, "User Preferences: `isSessionAutoshielded` default doesn't match.") + } + + // MARK: - Remove all keys from the live UD environment + func testRemoveAll() throws { - let userDefaults = UserDefaults.standard + guard let userDefaults = UserDefaults.init(suiteName: "test") else { + XCTFail("User Preferences: UserDefaults.init(suiteName: "test") failed to initialize") + return + } // fill in the data UserPreferencesStorage.Constants.allCases.forEach { @@ -60,6 +225,8 @@ class UserPreferencesStorageTests: XCTestCase { // remove it storage?.removeAll() + .sink(receiveValue: { _ in }) + .store(in: &cancellables) // check the presence UserPreferencesStorage.Constants.allCases.forEach {