diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..caa5ce6 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,19 @@ +name: Swift + +on: [push] + +jobs: + build: + + runs-on: macOS-latest + + steps: + - uses: actions/checkout@v1 + - name: Build + run: swift build -v + - name: Prepare xcodeproj + run: swift package generate-xcodeproj + - name: Run tests + run: xcodebuild test -scheme CloudKitFeatureToggles-Package -destination platform="macOS" -enableCodeCoverage YES -derivedDataPath .build/derivedData + - name: Codecov + run: bash <(curl -s https://codecov.io/bash) -D .build/derivedData/ -t ${{ secrets.CODECOV_TOKEN }} -J '^CloudKitFeatureToggles$' \ No newline at end of file diff --git a/Package.swift b/Package.swift index 4cc44c5..da614fe 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,12 @@ import PackageDescription let package = Package( name: "CloudKitFeatureToggles", + platforms: [ + .iOS(SupportedPlatform.IOSVersion.v10), + .macOS(SupportedPlatform.MacOSVersion.v10_12), + .tvOS(SupportedPlatform.TVOSVersion.v9), + .watchOS(SupportedPlatform.WatchOSVersion.v3) + ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( diff --git a/README.md b/README.md index c08f77e..7d2eb6b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,99 @@ -# CloudKitFeatureToggles +# CloudKit FeatureToggles -A description of this package. +![](https://github.com/JonnyBeeGod/CloudKitFeatureToggles/workflows/Swift/badge.svg) +[![codecov](https://codecov.io/gh/JonnyBeeGod/CloudKitFeatureToggles/branch/master/graph/badge.svg?token=y21zGNAsLL)](https://codecov.io/gh/JonnyBeeGod/CloudKitFeatureToggles) + + + Swift Package Manager + +iOS + + Twitter: @jonezdotcom + + +## What does it do? +Feature Toggles offer a way to enable or disable certain features that are present in your codebase, switch environments or configurations or toggle between multiple implementations of a protocol - even in your live system at runtime. *CloudKit FeatureToggles* are implemented using `CloudKit` and are therefor associated with no run costs for the developer. Existing Feature Toggles can be changed in the [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard/) and are delivered immediately via silent push notifications to your users. + +## How to install? +CloudKitFeatureToggles is compatible with Swift Package Manager. To install, simply add this repository URL to your swift packages as package dependency in Xcode. +Alternatively, add this line to your `Package.swift` file: + +``` +dependencies: [ + .package(url: "https://github.com/JonnyBeeGod/CloudKitFeatureToggles", from: "0.1.0") +] +``` + +And don't forget to add the dependency to your target(s). + +## How to use? + +### CloudKit Preparations +1. If your application does not support CloudKit yet start with adding the `CloudKit` and `remote background notification` entitlements to your application +2. Add a new custom record type 'FeatureStatus' with two fields: + +| Field | Type | +| --- | --- | +| `featureName` | `String` | +| `isActive` | `Int64` | + +For each feature toggle you want to support in your application later add a new record in your CloudKit *public database*. + +### In your project +1. In your AppDelegate, initialize a `FeatureToggleApplicationService` and hook its two `UIApplicationDelegate` methods into the AppDelegate lifecycle like so: + +``` +func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return featureToggleApplicationService.application(application, didFinishLaunchingWithOptions: launchOptions) +} +func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + featureToggleApplicationService.application(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: completionHandler) +} + +``` +2. Anywhere in your code you can create an instance of `FeatureToggleUserDefaultsRepository` and call `retrieve` to fetch the current status of a feature toggle. + +> :warning: Note that `retrieve` returns the locally saved status of your toggle, this command does not trigger a fetch from CloudKit. Feature Toggles are fetched from CloudKit once at app start from within the `FeatureToggleApplicationService` `UIApplicationDelegate` hook. Additionally you can subscribe to updates whenever there was a change to the feature toggles in CloudKit as shown in the next section. + +3. You have to call `retrieve` with your implementation of a `FeatureToggleIdentifiable`. What I think works well is creating an enum which implements `FeatureToggleIdentifiable`: + +``` +enum FeatureToggle: String, FeatureToggleIdentifiable { + case feature1 + case feature2 + + var identifier: String { + return self.rawValue + } + + var fallbackValue: Bool { + switch self { + case .feature1: + return false + case .feature2: + return true + } + } + } +``` +### Notifications + +You can subscribe to updates from your feature toggles in CloudKit by subscribing to the `onRecordsUpdated` Notification like so: + +``` +NotificationCenter.default.addObserver(self, selector: #selector(updateToggleStatusFromNotification), name: NSNotification.Name.onRecordsUpdated, object: nil) +``` + +``` +@objc +private func updateToggleStatusFromNotification(notification NSNotification) { + guard let updatedToggles = notification.userInfo[Notification.featureToggleUserInfoKey] as? [FeatureToggle] else { + return + } + + // do something with the updated toggle like e.g. disabling UI elements +} +``` + +Note that the updated Feature Toggles are attached to the notifications userInfo dictionary. When this notification has been sent the updated values are also already stored in the repository. diff --git a/Sources/CloudKitFeatureToggles/CloudKitFeatureToggles.swift b/Sources/CloudKitFeatureToggles/CloudKitFeatureToggles.swift deleted file mode 100644 index 07b03d2..0000000 --- a/Sources/CloudKitFeatureToggles/CloudKitFeatureToggles.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct CloudKitFeatureToggles { - var text = "Hello, World!" -} diff --git a/Sources/CloudKitFeatureToggles/CloudKitSubscriptionProtocol.swift b/Sources/CloudKitFeatureToggles/CloudKitSubscriptionProtocol.swift new file mode 100644 index 0000000..cf3e62f --- /dev/null +++ b/Sources/CloudKitFeatureToggles/CloudKitSubscriptionProtocol.swift @@ -0,0 +1,86 @@ +// +// CloudKitSubscriptionProtocol.swift +// nSuns +// +// Created by Jonas Reichert on 01.09.18. +// Copyright © 2018 Jonas Reichert. All rights reserved. +// + +import Foundation +import CloudKit + +protocol CloudKitDatabaseConformable { + func add(_ operation: CKDatabaseOperation) + func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZone.ID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void) +} + +extension CKDatabase: CloudKitDatabaseConformable {} + +protocol CloudKitSubscriptionProtocol { + var subscriptionID: String { get } + var database: CloudKitDatabaseConformable { get } + + func fetchAll() + func saveSubscription() + func handleNotification() +} + +extension CloudKitSubscriptionProtocol { + func saveSubscription(subscriptionID: String, recordType: String, defaults: UserDefaults) { + // Let's keep a local flag handy to avoid saving the subscription more than once. + // Even if you try saving the subscription multiple times, the server doesn't save it more than once + // Nevertheless, let's save some network operation and conserve resources + let subscriptionSaved = defaults.bool(forKey: subscriptionID) + guard !subscriptionSaved else { + return + } + + // Subscribing is nothing but saving a query which the server would use to generate notifications. + // The below predicate (query) will raise a notification for all changes. + let predicate = NSPredicate(value: true) + let subscription = CKQuerySubscription(recordType: recordType, predicate: predicate, subscriptionID: subscriptionID, options: CKQuerySubscription.Options.firesOnRecordUpdate) + + let notificationInfo = CKSubscription.NotificationInfo() + // Set shouldSendContentAvailable to true for receiving silent pushes + // Silent notifications are not shown to the user and don’t require the user's permission. + notificationInfo.shouldSendContentAvailable = true + subscription.notificationInfo = notificationInfo + + // Use CKModifySubscriptionsOperation to save the subscription to CloudKit + let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) + operation.modifySubscriptionsCompletionBlock = { (_, _, error) in + guard error == nil else { + return + } + defaults.set(true, forKey: subscriptionID) + } + operation.qualityOfService = .utility + // Add the operation to the corresponding private or public database + database.add(operation) + } + + func handleNotification(recordType: String, recordFetchedBlock: @escaping (CKRecord) -> Void) { + let queryOperation = CKQueryOperation(query: query(recordType: recordType)) + + queryOperation.recordFetchedBlock = recordFetchedBlock + queryOperation.qualityOfService = .utility + + database.add(queryOperation) + } + + func fetchAll(recordType: String, handler: @escaping ([CKRecord]) -> Void) { + database.perform(query(recordType: recordType), inZoneWith: nil) { (ckRecords, error) in + guard error == nil, let ckRecords = ckRecords else { + // don't update last fetched date, simply do nothing and try again next time + return + } + + handler(ckRecords) + } + } + + private func query(recordType: String) -> CKQuery { + let predicate = NSPredicate(value: true) + return CKQuery(recordType: recordType, predicate: predicate) + } +} diff --git a/Sources/CloudKitFeatureToggles/FeatureToggleApplicationService.swift b/Sources/CloudKitFeatureToggles/FeatureToggleApplicationService.swift new file mode 100644 index 0000000..acc66c7 --- /dev/null +++ b/Sources/CloudKitFeatureToggles/FeatureToggleApplicationService.swift @@ -0,0 +1,72 @@ +// +// FeatureToggleApplicationService.swift +// CloudKitFeatureToggles +// +// Created by Jonas Reichert on 04.01.20. +// + +import Foundation +import CloudKit +#if canImport(UIKit) +import UIKit +#endif + +public protocol FeatureToggleApplicationServiceProtocol { + var featureToggleRepository: FeatureToggleRepository { get } + + #if canImport(UIKit) + func register(application: UIApplication) + func handleRemoteNotification(subscriptionID: String?, completionHandler: @escaping (UIBackgroundFetchResult) -> Void) + #endif +} + +public class FeatureToggleApplicationService: NSObject, FeatureToggleApplicationServiceProtocol { + + private var featureToggleSubscriptor: CloudKitSubscriptionProtocol + private (set) public var featureToggleRepository: FeatureToggleRepository + + public convenience init(featureToggleRepository: FeatureToggleRepository = FeatureToggleUserDefaultsRepository()) { + self.init(featureToggleSubscriptor: FeatureToggleSubscriptor(toggleRepository: featureToggleRepository), featureToggleRepository: featureToggleRepository) + } + + init(featureToggleSubscriptor: CloudKitSubscriptionProtocol, featureToggleRepository: FeatureToggleRepository) { + self.featureToggleSubscriptor = featureToggleSubscriptor + self.featureToggleRepository = featureToggleRepository + } + + #if canImport(UIKit) + public func register(application: UIApplication) { + application.registerForRemoteNotifications() + featureToggleSubscriptor.saveSubscription() + featureToggleSubscriptor.fetchAll() + } + + public func handleRemoteNotification(subscriptionID: String?, completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + if let subscriptionID = subscriptionID, featureToggleSubscriptor.subscriptionID == subscriptionID { + featureToggleSubscriptor.handleNotification() + completionHandler(.newData) + } + else { + completionHandler(.noData) + } + } + #endif +} + +#if canImport(UIKit) +extension FeatureToggleApplicationService: UIApplicationDelegate { + public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + register(application: application) + + return true + } + + public func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo), let subscriptionID = notification.subscriptionID else { + return + } + + handleRemoteNotification(subscriptionID: subscriptionID, completionHandler: completionHandler) + } +} +#endif diff --git a/Sources/CloudKitFeatureToggles/FeatureToggleMapper.swift b/Sources/CloudKitFeatureToggles/FeatureToggleMapper.swift new file mode 100644 index 0000000..404537e --- /dev/null +++ b/Sources/CloudKitFeatureToggles/FeatureToggleMapper.swift @@ -0,0 +1,46 @@ +// +// FeatureToggleMapper.swift +// CloudKitFeatureToggles +// +// Created by Jonas Reichert on 06.01.20. +// + +import Foundation +import CloudKit + +public protocol FeatureToggleRepresentable { + var identifier: String { get } + var isActive: Bool { get } +} + +public protocol FeatureToggleIdentifiable { + var identifier: String { get } + var fallbackValue: Bool { get } +} + +public struct FeatureToggle: FeatureToggleRepresentable, Equatable { + public let identifier: String + public let isActive: Bool +} + +protocol FeatureToggleMappable { + func map(record: CKRecord) -> FeatureToggle? +} + +class FeatureToggleMapper: FeatureToggleMappable { + private let featureToggleNameFieldID: String + private let featureToggleIsActiveFieldID: String + + init(featureToggleNameFieldID: String, featureToggleIsActiveFieldID: String) { + self.featureToggleNameFieldID = featureToggleNameFieldID + self.featureToggleIsActiveFieldID = featureToggleIsActiveFieldID + } + + func map(record: CKRecord) -> FeatureToggle? { + guard let isActive = record[featureToggleIsActiveFieldID] as? Int64, let featureName = record[featureToggleNameFieldID] as? String else { + return nil + } + + return FeatureToggle(identifier: featureName, isActive: NSNumber(value: isActive).boolValue) + } +} diff --git a/Sources/CloudKitFeatureToggles/FeatureToggleRepository.swift b/Sources/CloudKitFeatureToggles/FeatureToggleRepository.swift new file mode 100644 index 0000000..ffb4163 --- /dev/null +++ b/Sources/CloudKitFeatureToggles/FeatureToggleRepository.swift @@ -0,0 +1,37 @@ +// +// FeatureToggleManager.swift +// CloudKitFeatureToggles +// +// Created by Jonas Reichert on 01.01.20. +// + +import Foundation + +public protocol FeatureToggleRepository { + /// retrieves a stored `FeatureToggleRepresentable` from the underlying store. + func retrieve(identifiable: FeatureToggleIdentifiable) -> FeatureToggleRepresentable + /// saves a supplied `FeatureToggleRepresentable` to the underlying store + func save(featureToggle: FeatureToggleRepresentable) +} + +public class FeatureToggleUserDefaultsRepository { + + private static let defaultsSuiteName = "featureToggleUserDefaultsRepositorySuite" + private let defaults: UserDefaults + + public init(defaults: UserDefaults? = nil) { + self.defaults = defaults ?? UserDefaults(suiteName: FeatureToggleUserDefaultsRepository.defaultsSuiteName) ?? .standard + } +} + +extension FeatureToggleUserDefaultsRepository: FeatureToggleRepository { + public func retrieve(identifiable: FeatureToggleIdentifiable) -> FeatureToggleRepresentable { + let isActive = defaults.value(forKey: identifiable.identifier) as? Bool + + return FeatureToggle(identifier: identifiable.identifier, isActive: isActive ?? identifiable.fallbackValue) + } + + public func save(featureToggle: FeatureToggleRepresentable) { + defaults.set(featureToggle.isActive, forKey: featureToggle.identifier) + } +} diff --git a/Sources/CloudKitFeatureToggles/FeatureToggleSubscriptor.swift b/Sources/CloudKitFeatureToggles/FeatureToggleSubscriptor.swift new file mode 100644 index 0000000..c82fbc2 --- /dev/null +++ b/Sources/CloudKitFeatureToggles/FeatureToggleSubscriptor.swift @@ -0,0 +1,67 @@ +// +// FeatureSwitchHelper.swift +// nSuns +// +// Created by Jonas Reichert on 22.07.18. +// Copyright © 2018 Jonas Reichert. All rights reserved. +// + +import Foundation +import CloudKit + +class FeatureToggleSubscriptor: CloudKitSubscriptionProtocol { + + private static let defaultsSuiteName = "featureToggleDefaultsSuite" + private let featureToggleRecordID: String + + private let toggleRepository: FeatureToggleRepository + private let toggleMapper: FeatureToggleMappable + private let defaults: UserDefaults + private let notificationCenter: NotificationCenter + + let subscriptionID = "cloudkit-recordType-FeatureToggle" + let database: CloudKitDatabaseConformable + + init(toggleRepository: FeatureToggleRepository = FeatureToggleUserDefaultsRepository(), toggleMapper: FeatureToggleMappable? = nil, featureToggleRecordID: String = "FeatureStatus", featureToggleNameFieldID: String = "featureName", featureToggleIsActiveFieldID: String = "isActive", defaults: UserDefaults = UserDefaults(suiteName: FeatureToggleSubscriptor.defaultsSuiteName) ?? .standard, notificationCenter: NotificationCenter = .default, cloudKitDatabaseConformable: CloudKitDatabaseConformable = CKContainer.default().publicCloudDatabase) { + self.toggleRepository = toggleRepository + self.toggleMapper = toggleMapper ?? FeatureToggleMapper(featureToggleNameFieldID: featureToggleNameFieldID, featureToggleIsActiveFieldID: featureToggleIsActiveFieldID) + self.featureToggleRecordID = featureToggleRecordID + self.defaults = defaults + self.notificationCenter = notificationCenter + self.database = cloudKitDatabaseConformable + } + + func fetchAll() { + fetchAll(recordType: featureToggleRecordID, handler: { (ckRecords) in + let toggles = ckRecords.compactMap { (record) -> FeatureToggle? in + return self.toggleMapper.map(record: record) + } + + self.updateRepository(with: toggles) + self.sendNotification(with: toggles) + }) + } + + func saveSubscription() { + saveSubscription(subscriptionID: subscriptionID, recordType: featureToggleRecordID, defaults: defaults) + } + + func handleNotification() { + handleNotification(recordType: featureToggleRecordID) { (record) in + let toggle = self.toggleMapper.map(record: record) + + self.updateRepository(with: [toggle].compactMap { $0 }) + self.sendNotification(with: [toggle].compactMap { $0 }) + } + } + + private func updateRepository(with toggles: [FeatureToggle]) { + toggles.forEach { (toggle) in + toggleRepository.save(featureToggle: toggle) + } + } + + private func sendNotification(with toggles: [FeatureToggle]) { + notificationCenter.post(name: Notification.Name.onRecordsUpdated, object: nil, userInfo: [Notification.featureTogglesUserInfoKey : toggles]) + } +} diff --git a/Sources/CloudKitFeatureToggles/Notification+Extensions.swift b/Sources/CloudKitFeatureToggles/Notification+Extensions.swift new file mode 100644 index 0000000..853200c --- /dev/null +++ b/Sources/CloudKitFeatureToggles/Notification+Extensions.swift @@ -0,0 +1,16 @@ +// +// Notification+Extensions.swift +// CloudKitFeatureToggles +// +// Created by Jonas Reichert on 06.01.20. +// + +import Foundation + +extension Notification.Name { + public static let onRecordsUpdated = Notification.Name("ckFeatureTogglesRecordsUpdatedNotification") +} + +extension Notification { + public static let featureTogglesUserInfoKey = "featureToggles" +} diff --git a/Tests/CloudKitFeatureTogglesTests/CloudKitFeatureTogglesTests.swift b/Tests/CloudKitFeatureTogglesTests/CloudKitFeatureTogglesTests.swift deleted file mode 100644 index c968c5f..0000000 --- a/Tests/CloudKitFeatureTogglesTests/CloudKitFeatureTogglesTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import XCTest -@testable import CloudKitFeatureToggles - -final class CloudKitFeatureTogglesTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(CloudKitFeatureToggles().text, "Hello, World!") - } - - static var allTests = [ - ("testExample", testExample), - ] -} diff --git a/Tests/CloudKitFeatureTogglesTests/FeatureToggleApplicationServiceTests.swift b/Tests/CloudKitFeatureTogglesTests/FeatureToggleApplicationServiceTests.swift new file mode 100644 index 0000000..9d07c53 --- /dev/null +++ b/Tests/CloudKitFeatureTogglesTests/FeatureToggleApplicationServiceTests.swift @@ -0,0 +1,77 @@ +// +// FeatureToggleApplicationServiceTests.swift +// CloudKitFeatureTogglesTests +// +// Created by Jonas Reichert on 05.01.20. +// + +import XCTest +@testable import CloudKitFeatureToggles + +class FeatureToggleApplicationServiceTests: XCTestCase { + + var repository: MockToggleRepository! + var subscriptor: MockFeatureToggleSubscriptor! + var subject: FeatureToggleApplicationServiceProtocol! + + override func setUp() { + repository = MockToggleRepository() + subscriptor = MockFeatureToggleSubscriptor() + subject = FeatureToggleApplicationService(featureToggleSubscriptor: subscriptor, featureToggleRepository: repository) + } + + func testRegister() { + #if canImport(UIKit) + XCTAssertFalse(subscriptor.saveSubscriptionCalled) + XCTAssertFalse(subscriptor.handleCalled) + XCTAssertFalse(subscriptor.fetchAllCalled) + + subject.register(application: UIApplication.shared) + + XCTAssertTrue(subscriptor.saveSubscriptionCalled) + XCTAssertFalse(subscriptor.handleCalled) + XCTAssertTrue(subscriptor.fetchAllCalled) + #endif + } + + func testHandle() { + #if canImport(UIKit) + XCTAssertFalse(subscriptor.saveSubscriptionCalled) + XCTAssertFalse(subscriptor.handleCalled) + XCTAssertFalse(subscriptor.fetchAllCalled) + + subject.handleRemoteNotification(subscriptionID: "Mock", completionHandler: { result in + + }) + XCTAssertFalse(subscriptor.saveSubscriptionCalled) + XCTAssertTrue(subscriptor.handleCalled) + XCTAssertFalse(subscriptor.fetchAllCalled) + #endif + } + + static var allTests = [ + ("testRegister", testRegister), + ("testHandle", testHandle), + ] +} + +class MockFeatureToggleSubscriptor: CloudKitSubscriptionProtocol { + var subscriptionID: String = "Mock" + var database: CloudKitDatabaseConformable = MockCloudKitDatabaseConformable() + + var saveSubscriptionCalled = false + var handleCalled = false + var fetchAllCalled = false + + func handleNotification() { + handleCalled = true + } + + func saveSubscription() { + saveSubscriptionCalled = true + } + + func fetchAll() { + fetchAllCalled = true + } +} diff --git a/Tests/CloudKitFeatureTogglesTests/FeatureToggleMapperTests.swift b/Tests/CloudKitFeatureTogglesTests/FeatureToggleMapperTests.swift new file mode 100644 index 0000000..a174253 --- /dev/null +++ b/Tests/CloudKitFeatureTogglesTests/FeatureToggleMapperTests.swift @@ -0,0 +1,90 @@ +// +// FeatureToggleMapperTests.swift +// CloudKitFeatureTogglesTests +// +// Created by Jonas Reichert on 06.01.20. +// + +import XCTest +import CloudKit +@testable import CloudKitFeatureToggles + +class FeatureToggleMapperTests: XCTestCase { + + var subject: FeatureToggleMappable! + + override func setUp() { + subject = FeatureToggleMapper(featureToggleNameFieldID: "featureName", featureToggleIsActiveFieldID: "isActive") + } + + func testMapInvalidInput() { + let everythingWrong = CKRecord(recordType: "RecordType", recordID: CKRecord.ID(recordName: "identifier")) + everythingWrong["bla"] = true + everythingWrong["muh"] = 1283765 + + XCTAssertNil(subject.map(record: everythingWrong)) + + let wrongFields = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier2")) + wrongFields["bla"] = true + wrongFields["muh"] = 1283765 + + XCTAssertNil(subject.map(record: wrongFields)) + + let wrongIsActiveField = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier3")) + wrongIsActiveField["bla"] = true + wrongIsActiveField["featureName"] = 1283765 + + XCTAssertNil(subject.map(record: wrongIsActiveField)) + + let wrongFeatureNameField = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier4")) + wrongFeatureNameField["isActive"] = true + wrongFeatureNameField["muh"] = 1283765 + + XCTAssertNil(subject.map(record: wrongFeatureNameField)) + + let wrongIsActiveType = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier5")) + wrongIsActiveType["isActive"] = "true" + wrongIsActiveType["featureName"] = "1283765" + + XCTAssertNil(subject.map(record: wrongIsActiveType)) + + let wrongFeatureNameType = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier6")) + wrongFeatureNameType["isActive"] = true + wrongFeatureNameType["featureName"] = 1283765 + + XCTAssertNil(subject.map(record: wrongFeatureNameType)) + } + + func testMap() { + let expectedIdentifier = "1283765" + let expectedIsActive = true + + let record = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier")) + record["isActive"] = expectedIsActive + record["featureName"] = expectedIdentifier + + let result = subject.map(record: record) + XCTAssertNotNil(result) + XCTAssertEqual(result, FeatureToggle(identifier: expectedIdentifier, isActive: expectedIsActive)) + } + + func testMap2() { + let expectedIdentifier = "akjshgdjaskd(/(/&%$§" + let expectedIsActive = false + + let record = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier")) + record["isActive"] = expectedIsActive + record["featureName"] = expectedIdentifier + + let result = subject.map(record: record) + XCTAssertNotNil(result) + XCTAssertEqual(result, FeatureToggle(identifier: expectedIdentifier, isActive: expectedIsActive)) + } + + static var allTests = [ + ("testMapInvalidInput", testMapInvalidInput), + ("testMap", testMap), + ("testMap2", testMap2), + ] + +} diff --git a/Tests/CloudKitFeatureTogglesTests/FeatureToggleRepositoryTests.swift b/Tests/CloudKitFeatureTogglesTests/FeatureToggleRepositoryTests.swift new file mode 100644 index 0000000..ae61e77 --- /dev/null +++ b/Tests/CloudKitFeatureTogglesTests/FeatureToggleRepositoryTests.swift @@ -0,0 +1,81 @@ +// +// FeatureToggleRepositoryTests.swift +// CloudKitFeatureTogglesTests +// +// Created by Jonas Reichert on 01.01.20. +// + +import XCTest +@testable import CloudKitFeatureToggles + +class FeatureToggleRepositoryTests: XCTestCase { + + enum TestToggle: String, FeatureToggleIdentifiable { + var identifier: String { + return self.rawValue + } + + var fallbackValue: Bool { + switch self { + case .feature1: + return false + case .feature2: + return true + } + } + + case feature1 + case feature2 + } + + let suiteName = "repositoryTest" + var defaults: UserDefaults! + + var subject: FeatureToggleRepository! + + override func setUp() { + super.setUp() + + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail() + return + } + + self.defaults = defaults + self.subject = FeatureToggleUserDefaultsRepository(defaults: defaults) + } + + override func tearDown() { + self.defaults.removePersistentDomain(forName: suiteName) + + super.tearDown() + } + + func testRetrieveBeforeSave() { + XCTAssertEqual(subject.retrieve(identifiable: TestToggle.feature1).isActive, TestToggle.feature1.fallbackValue) + XCTAssertEqual(subject.retrieve(identifiable: TestToggle.feature2).isActive, TestToggle.feature2.fallbackValue) + + XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature1).isActive) + subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature1.rawValue, isActive: true)) + XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).isActive) + } + + func testSaveAndRetrieve() { + XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature1).isActive) + XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature2).isActive) + + subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature1.rawValue, isActive: true)) + XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).isActive) + XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature2).isActive) + + subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature2.rawValue, isActive: false)) + XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).isActive) + XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature2).isActive) + } + + static var allTests = [ + ("testSaveAndRetrieve", testSaveAndRetrieve), + ("testRetrieveBeforeSave", testRetrieveBeforeSave), + ] + +} diff --git a/Tests/CloudKitFeatureTogglesTests/FeatureToggleSubscriptorTests.swift b/Tests/CloudKitFeatureTogglesTests/FeatureToggleSubscriptorTests.swift new file mode 100644 index 0000000..e00f77b --- /dev/null +++ b/Tests/CloudKitFeatureTogglesTests/FeatureToggleSubscriptorTests.swift @@ -0,0 +1,277 @@ +// +// FeatureToggleSubscriptorTests.swift +// CloudKitFeatureTogglesTests +// +// Created by Jonas Reichert on 02.01.20. +// + +import XCTest +import CloudKit +@testable import CloudKitFeatureToggles + +class FeatureToggleSubscriptorTests: XCTestCase { + + enum TestError: Error { + case generic + } + + var subject: FeatureToggleSubscriptor! + var cloudKitDatabase: MockCloudKitDatabaseConformable! + var repository: MockToggleRepository! + let defaults = UserDefaults(suiteName: "testSuite") ?? .standard + + override func setUp() { + super.setUp() + + cloudKitDatabase = MockCloudKitDatabaseConformable() + repository = MockToggleRepository() + subject = FeatureToggleSubscriptor(toggleRepository: repository, featureToggleRecordID: "TestFeatureStatus", featureToggleNameFieldID: "toggleName", featureToggleIsActiveFieldID: "isActive", defaults: defaults, cloudKitDatabaseConformable: cloudKitDatabase) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: "testSuite") + + super.tearDown() + } + + func testFetchAll() { + XCTAssertNil(cloudKitDatabase.recordType) + XCTAssertEqual(repository.toggles.count, 0) + + cloudKitDatabase.recordFetched["isActive"] = 1 + cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + + subject.fetchAll() + + XCTAssertEqual(repository.toggles.count, 1) + guard let toggle = repository.toggles.first else { + XCTFail() + return + } + XCTAssertEqual(toggle.identifier, "Toggle1") + XCTAssertTrue(toggle.isActive) + + cloudKitDatabase.recordFetched["isActive"] = 0 + cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + + subject.fetchAll() + + XCTAssertEqual(repository.toggles.count, 1) + + guard let toggle2 = repository.toggles.first else { + XCTFail() + return + } + XCTAssertEqual(toggle2.identifier, "Toggle1") + XCTAssertFalse(toggle2.isActive) + } + + func testFetchAllError() { + cloudKitDatabase.error = TestError.generic + + XCTAssertNil(cloudKitDatabase.recordType) + XCTAssertEqual(repository.toggles.count, 0) + + cloudKitDatabase.recordFetched["isActive"] = 1 + cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + + subject.fetchAll() + + XCTAssertNil(cloudKitDatabase.recordType) + XCTAssertEqual(repository.toggles.count, 0) + } + + func testFetchAllNotification() { + let expectation = self.expectation(forNotification: NSNotification.Name.onRecordsUpdated, object: nil) { (notification) -> Bool in + guard let userInfo = notification.userInfo, let toggles = userInfo["featureToggles"] as? [FeatureToggle] else { + return false + } + + return toggles.count == 1 + } + cloudKitDatabase.recordFetched["isActive"] = 1 + cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + + subject.fetchAll() + wait(for: [expectation], timeout: 0.1) + } + + func testFetchAllNotMappableRecord() { + let expectation = self.expectation(forNotification: NSNotification.Name.onRecordsUpdated, object: nil) { (notification) -> Bool in + guard let userInfo = notification.userInfo, let toggles = userInfo["featureToggles"] as? [FeatureToggle] else { + return false + } + + return toggles.count == 0 + } + cloudKitDatabase.recordFetched["isActive123"] = 1 + cloudKitDatabase.recordFetched["toggleName1234"] = "Toggle1" + + subject.fetchAll() + wait(for: [expectation], timeout: 0.1) + XCTAssertEqual(repository.toggles.count, 0) + } + + func testSaveSubscription() { + XCTAssertNil(cloudKitDatabase.subscriptionsToSave) + XCTAssertFalse(defaults.bool(forKey: subject.subscriptionID)) + + subject.saveSubscription() + + guard let firstSubscription = cloudKitDatabase.subscriptionsToSave?.first else { + XCTFail() + return + } + + XCTAssertEqual(firstSubscription.subscriptionID, subject.subscriptionID) + XCTAssertTrue(defaults.bool(forKey: subject.subscriptionID)) + XCTAssertEqual(cloudKitDatabase.addCalledCount, 1) + + subject.saveSubscription() + XCTAssertEqual(firstSubscription.subscriptionID, subject.subscriptionID) + XCTAssertTrue(defaults.bool(forKey: subject.subscriptionID)) + XCTAssertEqual(cloudKitDatabase.addCalledCount, 1) + } + + func testSaveSubscriptionError() { + cloudKitDatabase.error = TestError.generic + + XCTAssertNil(cloudKitDatabase.subscriptionsToSave) + XCTAssertFalse(defaults.bool(forKey: subject.subscriptionID)) + + subject.saveSubscription() + + XCTAssertFalse(defaults.bool(forKey: subject.subscriptionID)) + } + + func testHandleNotification() { + XCTAssertNil(cloudKitDatabase.recordType) + XCTAssertEqual(cloudKitDatabase.addCalledCount, 0) + XCTAssertEqual(repository.toggles.count, 0) + + cloudKitDatabase.recordFetched["isActive"] = 1 + cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + + subject.handleNotification() + + XCTAssertEqual(cloudKitDatabase.addCalledCount, 1) + XCTAssertEqual(cloudKitDatabase.recordType, "TestFeatureStatus") + XCTAssertEqual(repository.toggles.count, 1) + guard let toggle = repository.toggles.first else { + XCTFail() + return + } + XCTAssertEqual(toggle.identifier, "Toggle1") + XCTAssertTrue(toggle.isActive) + + cloudKitDatabase.recordFetched["isActive"] = 0 + cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + + subject.handleNotification() + + XCTAssertEqual(cloudKitDatabase.addCalledCount, 2) + XCTAssertEqual(cloudKitDatabase.recordType, "TestFeatureStatus") + XCTAssertEqual(repository.toggles.count, 1) + + guard let toggle2 = repository.toggles.first else { + XCTFail() + return + } + XCTAssertEqual(toggle2.identifier, "Toggle1") + XCTAssertFalse(toggle2.isActive) + } + + func testHandleNotificationSendNotification() { + let expectation = self.expectation(forNotification: NSNotification.Name.onRecordsUpdated, object: nil) { (notification) -> Bool in + guard let userInfo = notification.userInfo, let toggles = userInfo["featureToggles"] as? [FeatureToggle] else { + return false + } + + return toggles.count == 1 + } + cloudKitDatabase.recordFetched["isActive"] = 1 + cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + + subject.handleNotification() + wait(for: [expectation], timeout: 0.1) + + } + + func testHandleNotificationNotMappableRecord() { + let expectation = self.expectation(forNotification: NSNotification.Name.onRecordsUpdated, object: nil) { (notification) -> Bool in + guard let userInfo = notification.userInfo, let toggles = userInfo["featureToggles"] as? [FeatureToggle] else { + return false + } + + return toggles.count == 0 + } + + cloudKitDatabase.recordFetched["isActive123"] = 1 + cloudKitDatabase.recordFetched["toggleName1234"] = "Toggle1" + + subject.handleNotification() + wait(for: [expectation], timeout: 0.1) + XCTAssertEqual(repository.toggles.count, 0) + } + + static var allTests = [ + ("testFetchAll", testFetchAll), + ("testFetchAllError", testFetchAllError), + ("testFetchAllNotification", testFetchAllNotification), + ("testSaveSubscription", testSaveSubscription), + ("testSaveSubscriptionError", testSaveSubscriptionError), + ("testHandleNotification", testHandleNotification), + ("testHandleNotificationSendNotification", testHandleNotificationSendNotification), + ] + +} + +class MockToggleRepository: FeatureToggleRepository { + var toggles: [FeatureToggleRepresentable] = [] + + func save(featureToggle: FeatureToggleRepresentable) { + toggles.removeAll { (representable) -> Bool in + representable.identifier == featureToggle.identifier + } + toggles.append(featureToggle) + } + + func retrieve(identifiable: FeatureToggleIdentifiable) -> FeatureToggleRepresentable { + toggles.first { (representable) -> Bool in + representable.identifier == identifiable.identifier + } ?? MockToggleRepresentable(identifier: identifiable.identifier, isActive: identifiable.fallbackValue) + } +} + +struct MockToggleRepresentable: FeatureToggleRepresentable { + var identifier: String + var isActive: Bool +} + +class MockCloudKitDatabaseConformable: CloudKitDatabaseConformable { + var addCalledCount = 0 + var subscriptionsToSave: [CKSubscription]? + var recordType: CKRecord.RecordType? + + var recordFetched = CKRecord(recordType: "TestFeatureStatus") + var error: Error? + + func add(_ operation: CKDatabaseOperation) { + if let op = operation as? CKModifySubscriptionsOperation { + subscriptionsToSave = op.subscriptionsToSave + op.modifySubscriptionsCompletionBlock?(nil, nil, error) + } else if let op = operation as? CKQueryOperation { + recordType = op.query?.recordType + op.recordFetchedBlock?(recordFetched) + } + addCalledCount += 1 + } + + func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZone.ID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void) { + if let error = error { + completionHandler(nil, error) + } else { + completionHandler([recordFetched], error) + } + } +} diff --git a/Tests/CloudKitFeatureTogglesTests/XCTestManifests.swift b/Tests/CloudKitFeatureTogglesTests/XCTestManifests.swift index 96aae70..93b9594 100644 --- a/Tests/CloudKitFeatureTogglesTests/XCTestManifests.swift +++ b/Tests/CloudKitFeatureTogglesTests/XCTestManifests.swift @@ -3,7 +3,10 @@ import XCTest #if !canImport(ObjectiveC) public func allTests() -> [XCTestCaseEntry] { return [ - testCase(CloudKitFeatureTogglesTests.allTests), + testCase(FeatureToggleRepositoryTests.allTests), + testCase(FeatureToggleSubscriptorTests.allTests), + testCase(FeatureToggleApplicationServiceTests.allTests), + testCase(FeatureToggleMapperTests.allTests), ] } #endif