diff --git a/Bucketeer/Sources/Public/BKTClient.swift b/Bucketeer/Sources/Public/BKTClient.swift index 1b4e4d1e..10e069d6 100644 --- a/Bucketeer/Sources/Public/BKTClient.swift +++ b/Bucketeer/Sources/Public/BKTClient.swift @@ -73,8 +73,10 @@ public class BKTClient { } extension BKTClient { - public static func initialize(config: BKTConfig, user: BKTUser, timeoutMillis: Int64 = 5000, completion: ((BKTError?) -> Void)? = nil) { - precondition(Thread.isMainThread, "the initialize method must be called on main thread") + public static func initialize(config: BKTConfig, user: BKTUser, timeoutMillis: Int64 = 5000, completion: ((BKTError?) -> Void)? = nil) throws { + guard (Thread.isMainThread) else { + throw BKTError.illegalState(message: "the initialize method must be called on main thread") + } concurrentQueue.sync { guard BKTClient.default == nil else { config.logger?.warn(message: "BKTClient is already initialized. Not sure if the initial fetch has finished") @@ -98,14 +100,25 @@ extension BKTClient { } } - public static func destroy() { - precondition(Thread.isMainThread, "the destroy method must be called on main thread") + public static func destroy() throws { + guard (Thread.isMainThread) else { + throw BKTError.illegalState(message: "the destroy method must be called on main thread") + } BKTClient.default?.resetTasks() BKTClient.default = nil } + // Please make sure the BKTClient is initialize before access it public static var shared: BKTClient { - return BKTClient.default ?? { fatalError("BKTClient is already initialized. Not sure if the initial fetch has finished") }() + get throws { + // We do not want to crash the SDK's consumer app on runtime by using fatalError(). + // So let the app has a chance to catch this exception + // The same behavior with the Android SDK + guard BKTClient.default != nil else { + throw BKTError.illegalState(message: "BKTClient is not initialized") + } + return BKTClient.default + } } public func stringVariation(featureId: String, defaultValue: String) -> String { diff --git a/BucketeerTests/BKTClientTests.swift b/BucketeerTests/BKTClientTests.swift index 82206b72..c3a4bf8c 100644 --- a/BucketeerTests/BKTClientTests.swift +++ b/BucketeerTests/BKTClientTests.swift @@ -4,6 +4,57 @@ import XCTest // swiftlint:disable type_body_length final class BKTClientTests: XCTestCase { + func testMainThreadRequired() throws { + let expectation = self.expectation(description: "") + expectation.expectedFulfillmentCount = 4 + + let config = BKTConfig.mock1 + let user = try BKTUser.Builder().with(id: USER_ID).build() + let threadQueue = DispatchQueue(label: "threads") + + threadQueue.async { + do { + try BKTClient.initialize( + config: config, + user: user, completion: { _ in + } + ) + } catch { + // Should catch error, because we didn't on the main thread + expectation.fulfill() + } + + DispatchQueue.main.sync { + do { + try BKTClient.initialize( + config: config, + user: user, completion: { _ in + } + ) + // Should success and fullfill + expectation.fulfill() + } catch {} + } + + do { + try BKTClient.destroy() + } catch { + // Should catch error, because we didn't on the main thread + expectation.fulfill() + } + + DispatchQueue.main.sync { + do { + try BKTClient.destroy() + // Should success and fullfill + expectation.fulfill() + } catch {} + } + } + + wait(for: [expectation], timeout: 1) + } + func testCurrentUser() { let dataModule = MockDataModule( userHolder: .init(user: .mock1) diff --git a/BucketeerTests/E2E/BucketeerE2ETests.swift b/BucketeerTests/E2E/BucketeerE2ETests.swift index 548622e5..1df0835d 100644 --- a/BucketeerTests/E2E/BucketeerE2ETests.swift +++ b/BucketeerTests/E2E/BucketeerE2ETests.swift @@ -25,140 +25,198 @@ final class BucketeerE2ETests: XCTestCase { try await super.tearDown() try await BKTClient.shared.flush() - BKTClient.destroy() + try BKTClient.destroy() UserDefaults.standard.removeObject(forKey: "bucketeer_user_evaluations_id") try FileManager.default.removeItem(at: .database) } func testStringVariation() { - let client = BKTClient.shared - XCTAssertEqual(client.stringVariation(featureId: FEATURE_ID_STRING, defaultValue: ""), "value-1") + do { + let client = try BKTClient.shared + XCTAssertEqual(client.stringVariation(featureId: FEATURE_ID_STRING, defaultValue: ""), "value-1") + } catch { + XCTFail(error.localizedDescription) + } } func testStringVariationDetail() { - let client = BKTClient.shared - let actual = client.evaluationDetails(featureId: FEATURE_ID_STRING) - - assertEvaluation(actual: actual, expected: .init( - id: "feature-ios-e2e-string:3:bucketeer-ios-user-id-1", - featureId: FEATURE_ID_STRING, - featureVersion: 3, - variationId: "349ed945-d2f9-4d04-8e83-82344cffd1ec", - variationName: "variation 1", - variationValue: "value-1", - reason: .default - )) + do { + let client = try BKTClient.shared + let actual = client.evaluationDetails(featureId: FEATURE_ID_STRING) + + assertEvaluation(actual: actual, expected: .init( + id: "feature-ios-e2e-string:3:bucketeer-ios-user-id-1", + featureId: FEATURE_ID_STRING, + featureVersion: 3, + variationId: "349ed945-d2f9-4d04-8e83-82344cffd1ec", + variationName: "variation 1", + variationValue: "value-1", + reason: .default + )) + } catch { + XCTFail(error.localizedDescription) + } } func testIntVariation() { - let client = BKTClient.shared - XCTAssertEqual(client.intVariation(featureId: FEATURE_ID_INT, defaultValue: 0), 10) + do { + let client = try BKTClient.shared + XCTAssertEqual(client.intVariation(featureId: FEATURE_ID_INT, defaultValue: 0), 10) + } catch { + XCTFail(error.localizedDescription) + } } func testIntVariationDetail() { - let client = BKTClient.shared - let actual = client.evaluationDetails(featureId: FEATURE_ID_INT) - - assertEvaluation(actual: actual, expected: .init( - id: "feature-ios-e2e-integer:3:bucketeer-ios-user-id-1", - featureId: FEATURE_ID_INT, - featureVersion: 3, - variationId: "9c5fd2d2-d587-4ba2-8de2-0fc9454d564e", - variationName: "variation 10", - variationValue: "10", - reason: .default - )) + do { + let client = try BKTClient.shared + let actual = client.evaluationDetails(featureId: FEATURE_ID_INT) + + assertEvaluation(actual: actual, expected: .init( + id: "feature-ios-e2e-integer:3:bucketeer-ios-user-id-1", + featureId: FEATURE_ID_INT, + featureVersion: 3, + variationId: "9c5fd2d2-d587-4ba2-8de2-0fc9454d564e", + variationName: "variation 10", + variationValue: "10", + reason: .default + )) + } catch { + XCTFail(error.localizedDescription) + } } func testDoubleVariation() { - let client = BKTClient.shared - XCTAssertEqual(client.doubleVariation(featureId: FEATURE_ID_DOUBLE, defaultValue: 0.1), 2.1) + do { + let client = try BKTClient.shared + XCTAssertEqual(client.doubleVariation(featureId: FEATURE_ID_DOUBLE, defaultValue: 0.1), 2.1) + } catch { + XCTFail(error.localizedDescription) + } } func testDoubleVariationDetail() async throws { - let client = BKTClient.shared - let actual = client.evaluationDetails(featureId: FEATURE_ID_DOUBLE) - - assertEvaluation(actual: actual, expected: .init( - id: "feature-ios-e2e-double:3:bucketeer-ios-user-id-1", - featureId: FEATURE_ID_DOUBLE, - featureVersion: 3, - variationId: "38078d8f-c6eb-4b93-9d58-c3e57010983f", - variationName: "variation 2.1", - variationValue: "2.1", - reason: .default - )) + do { + let client = try BKTClient.shared + let actual = client.evaluationDetails(featureId: FEATURE_ID_DOUBLE) + + assertEvaluation(actual: actual, expected: .init( + id: "feature-ios-e2e-double:3:bucketeer-ios-user-id-1", + featureId: FEATURE_ID_DOUBLE, + featureVersion: 3, + variationId: "38078d8f-c6eb-4b93-9d58-c3e57010983f", + variationName: "variation 2.1", + variationValue: "2.1", + reason: .default + )) + } catch { + XCTFail(error.localizedDescription) + } } func testBoolVariation() { - let client = BKTClient.shared - XCTAssertEqual(client.boolVariation(featureId: FEATURE_ID_BOOLEAN, defaultValue: false), true) + do { + let client = try BKTClient.shared + let actual = client.evaluationDetails(featureId: FEATURE_ID_BOOLEAN) + + assertEvaluation(actual: actual, expected: .init( + id: "feature-ios-e2e-bool:3:bucketeer-ios-user-id-1", + featureId: FEATURE_ID_BOOLEAN, + featureVersion: 3, + variationId: "4f9e0f88-e053-42a9-93e1-95d407f67021", + variationName: "variation true", + variationValue: "true", + reason: .default + )) + } catch { + XCTFail(error.localizedDescription) + } } func testBoolVariationDetail() { - let client = BKTClient.shared - let actual = client.evaluationDetails(featureId: FEATURE_ID_BOOLEAN) - - assertEvaluation(actual: actual, expected: .init( - id: "feature-ios-e2e-bool:3:bucketeer-ios-user-id-1", - featureId: FEATURE_ID_BOOLEAN, - featureVersion: 3, - variationId: "4f9e0f88-e053-42a9-93e1-95d407f67021", - variationName: "variation true", - variationValue: "true", - reason: .default - )) + do { + let client = try BKTClient.shared + let actual = client.evaluationDetails(featureId: FEATURE_ID_BOOLEAN) + + assertEvaluation(actual: actual, expected: .init( + id: "feature-ios-e2e-bool:3:bucketeer-ios-user-id-1", + featureId: FEATURE_ID_BOOLEAN, + featureVersion: 3, + variationId: "4f9e0f88-e053-42a9-93e1-95d407f67021", + variationName: "variation true", + variationValue: "true", + reason: .default + )) + } catch { + XCTFail(error.localizedDescription) + } } func testJSONVariation() { - let client = BKTClient.shared - let json = client.jsonVariation(featureId: FEATURE_ID_JSON, defaultValue: [:]) - XCTAssertEqual(json as? [String: String], ["key": "value-1"]) + do { + let client = try BKTClient.shared + let json = client.jsonVariation(featureId: FEATURE_ID_JSON, defaultValue: [:]) + XCTAssertEqual(json as? [String: String], ["key": "value-1"]) + } catch { + XCTFail(error.localizedDescription) + } } func testJSONVariationDetail() { - let client = BKTClient.shared - let actual = client.evaluationDetails(featureId: FEATURE_ID_JSON) - - assertEvaluation(actual: actual, expected: .init( - id: "feature-ios-e2e-json:3:bucketeer-ios-user-id-1", - featureId: FEATURE_ID_JSON, - featureVersion: 3, - variationId: "06f5be6b-0c79-431f-a057-822babd9d3eb", - variationName: "variation 1", - variationValue: "{ \"key\": \"value-1\" }", - reason: .default - )) + do { + let client = try BKTClient.shared + let actual = client.evaluationDetails(featureId: FEATURE_ID_JSON) + + assertEvaluation(actual: actual, expected: .init( + id: "feature-ios-e2e-json:3:bucketeer-ios-user-id-1", + featureId: FEATURE_ID_JSON, + featureVersion: 3, + variationId: "06f5be6b-0c79-431f-a057-822babd9d3eb", + variationName: "variation 1", + variationValue: "{ \"key\": \"value-1\" }", + reason: .default + )) + } catch { + XCTFail(error.localizedDescription) + } } func testEvaluationUpdateFlow() async throws { - let client = BKTClient.shared - XCTAssertEqual(client.stringVariation(featureId: FEATURE_ID_STRING, defaultValue: ""), "value-1") - - client.updateUserAttributes(attributes: ["app_version": "0.0.1"]) - - try await client.fetchEvaluations(timeoutMillis: nil) - XCTAssertEqual(client.stringVariation(featureId: FEATURE_ID_STRING, defaultValue: ""), "value-2") - - let details = client.evaluationDetails(featureId: FEATURE_ID_STRING) - assertEvaluation(actual: details, expected: .init( - id: "feature-ios-e2e-string:3:bucketeer-ios-user-id-1", - featureId: FEATURE_ID_STRING, - featureVersion: 3, - variationId: "b4931643-e82f-4079-bd3c-aed02852cdd6", - variationName: "variation 2", - variationValue: "value-2", - reason: .rule - )) + do { + let client = try BKTClient.shared + XCTAssertEqual(client.stringVariation(featureId: FEATURE_ID_STRING, defaultValue: ""), "value-1") + + client.updateUserAttributes(attributes: ["app_version": "0.0.1"]) + + try await client.fetchEvaluations(timeoutMillis: nil) + XCTAssertEqual(client.stringVariation(featureId: FEATURE_ID_STRING, defaultValue: ""), "value-2") + + let details = client.evaluationDetails(featureId: FEATURE_ID_STRING) + assertEvaluation(actual: details, expected: .init( + id: "feature-ios-e2e-string:3:bucketeer-ios-user-id-1", + featureId: FEATURE_ID_STRING, + featureVersion: 3, + variationId: "b4931643-e82f-4079-bd3c-aed02852cdd6", + variationName: "variation 2", + variationValue: "value-2", + reason: .rule + )) + } catch { + XCTFail(error.localizedDescription) + } } func testTrack() async throws { - let client = BKTClient.shared - client.assert(expectedEventCount: 2) - client.track(goalId: GOAL_ID, value: GOAL_VALUE) - try await Task.sleep(nanoseconds: 1_000_000) - client.assert(expectedEventCount: 3) - try await client.flush() - client.assert(expectedEventCount: 0) + do { + let client = try BKTClient.shared + client.assert(expectedEventCount: 2) + client.track(goalId: GOAL_ID, value: GOAL_VALUE) + try await Task.sleep(nanoseconds: 1_000_000) + client.assert(expectedEventCount: 3) + try await client.flush() + client.assert(expectedEventCount: 0) + } catch { + XCTFail(error.localizedDescription) + } } } diff --git a/BucketeerTests/E2E/E2ETestHelpers.swift b/BucketeerTests/E2E/E2ETestHelpers.swift index 7cb922da..c5a65e55 100644 --- a/BucketeerTests/E2E/E2ETestHelpers.swift +++ b/BucketeerTests/E2E/E2ETestHelpers.swift @@ -35,12 +35,16 @@ extension BKTClient { static func initialize(config: BKTConfig, user: BKTUser, timeoutMillis: Int64 = 5000) async throws { return try await withCheckedThrowingContinuation { continuation in DispatchQueue.main.async { - self.initialize(config: config, user: user) { error in - if let error = error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: ()) + do { + try self.initialize(config: config, user: user) { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } } + } catch { + continuation.resume(throwing: error) } } } diff --git a/Example/AppDelegate.swift b/Example/AppDelegate.swift index 69c7ddce..d209bed6 100644 --- a/Example/AppDelegate.swift +++ b/Example/AppDelegate.swift @@ -14,33 +14,45 @@ class AppDelegate: UIResponder, UIApplicationDelegate { .with(attributes: [:]) .build() - BKTClient.initialize( - config: self.makeConfigUsingBuilder(), - user: user - ) { error in - if let error { - print(error) - } - let client = BKTClient.shared - client.updateUserAttributes(attributes: [:]) - print("intVariation =", client.intVariation(featureId: "feature-ios-e2e-integer", defaultValue: 0)) - print("doubleVariation =", client.doubleVariation(featureId: "feature-ios-e2e-double", defaultValue: 0.0)) - print("boolVariation =", client.boolVariation(featureId: "feature-ios-e2e-bool", defaultValue: false)) - print("stringVariation =", client.stringVariation(featureId: "feature-ios-e2e-string", defaultValue: "004 not found...")) - print("jsonVariation =", client.jsonVariation(featureId: "feature-ios-e2e-json", defaultValue: [:])) - DispatchQueue.main.async { - self.setSingleViewController() - } - - DispatchQueue.main.async { - let isTabMode = client.boolVariation(featureId: "ios_test_001", defaultValue: false) - if isTabMode { - self.setTabBarController() - } else { + do { + try BKTClient.initialize( + config: self.makeConfigUsingBuilder(), + user: user + ) { error in + if let error { + print(error) + } + var client : BKTClient? + do { + try client = BKTClient.shared + } catch { + print(error.localizedDescription) + } + client?.updateUserAttributes(attributes: [:]) + print("intVariation =", client?.intVariation(featureId: "feature-ios-e2e-integer", defaultValue: 0) ?? 0) + print("doubleVariation =", client?.doubleVariation(featureId: "feature-ios-e2e-double", defaultValue: 0.0) ?? 0.0) + print("boolVariation =", client?.boolVariation(featureId: "feature-ios-e2e-bool", defaultValue: false) ?? false) + print("stringVariation =", client?.stringVariation(featureId: "feature-ios-e2e-string", defaultValue: "004 not found...") ?? "004 not found...") + print("jsonVariation =", client?.jsonVariation(featureId: "feature-ios-e2e-json", defaultValue: [:]) ?? [:]) + DispatchQueue.main.async { self.setSingleViewController() } + + DispatchQueue.main.async { + let isTabMode = client?.boolVariation(featureId: "ios_test_001", defaultValue: false) ?? false + if isTabMode { + self.setTabBarController() + } else { + self.setSingleViewController() + } + } } + } catch { + // Handle exception when initialize the BKTClient, + // Usually because it required to call from the main thread + print(error.localizedDescription) } + return true } diff --git a/Example/FirstViewController.swift b/Example/FirstViewController.swift index aca324bf..656e46ab 100644 --- a/Example/FirstViewController.swift +++ b/Example/FirstViewController.swift @@ -4,15 +4,27 @@ import Bucketeer class FirstViewController: UIViewController { @IBOutlet weak var messageLabel: UILabel! + var client : BKTClient? + override func viewDidLoad() { + super.viewDidLoad() + do { + try client = BKTClient.shared + } catch { + // We may have an error when we did not success initialize the client + // Handle error + } + } + override func viewWillAppear(_ animated: Bool) { - messageLabel.text = BKTClient.shared.stringVariation(featureId: "ios_test_002", defaultValue: "not found...") - let colorCode = BKTClient.shared.stringVariation(featureId: "ios_test_003", defaultValue: "#999999") + messageLabel.text = client?.stringVariation(featureId: "ios_test_002", defaultValue: "not found...") ?? "not found..." + + let colorCode = client?.stringVariation(featureId: "ios_test_003", defaultValue: "#999999") ?? "#999999" view.backgroundColor = UIColor(hex: colorCode) } @IBAction func trackButtonAction(_ sender: Any) { - BKTClient.shared.track(goalId: "ios_test_002", value: 1) + client?.track(goalId: "ios_test_002", value: 1) } }