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)
+
+
+
+
+
+
+
+
+
+## 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