From 0f9c2eb0b6aa1550ea1ee4aa64be0902638a6ee2 Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Wed, 22 Oct 2025 17:25:21 -0300 Subject: [PATCH 01/15] Initial commit --- Package.resolved | 4 +- Package.swift | 14 +-- Sources/SplitProvider/Errors.swift | 7 ++ Sources/SplitProvider/EventHandler.swift | 16 ++++ Sources/SplitProvider/SplitInitContext.swift | 36 +++++++ Sources/SplitProvider/SplitProvider.swift | 93 +++++++++++++++++++ Sources/swift-provider/SplitProvider.swift | 63 ------------- .../SplitProviderTests.swift | 2 +- 8 files changed, 162 insertions(+), 73 deletions(-) create mode 100644 Sources/SplitProvider/Errors.swift create mode 100644 Sources/SplitProvider/EventHandler.swift create mode 100644 Sources/SplitProvider/SplitInitContext.swift create mode 100644 Sources/SplitProvider/SplitProvider.swift delete mode 100644 Sources/swift-provider/SplitProvider.swift rename Tests/{swift-providerTests => SplitProviderTests}/SplitProviderTests.swift (90%) diff --git a/Package.resolved b/Package.resolved index 7e4ae14..049ce9d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/splitio/ios-client.git", "state": { "branch": null, - "revision": "0833365635d3019f1283a9a39a5e3830f41daf34", - "version": "3.4.1" + "revision": "73b8b0d1e94f13383eee534cfea4ef657760f0ae", + "version": "3.4.2" } }, { diff --git a/Package.swift b/Package.swift index 807d6da..99bd63f 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( - name: "swift-provider", + name: "SplitProvider", platforms: [ .iOS(.v14), .macOS(.v11), @@ -14,28 +14,28 @@ let package = Package( products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( - name: "swift-provider", - targets: ["swift-provider"] + name: "SplitProvider", + targets: ["SplitProvider"] ) ], dependencies: [ // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/open-feature/swift-sdk.git", from: "0.4.0"), - .package(url: "https://github.com/splitio/ios-client.git", from: "3.4.1"), + .package(url: "https://github.com/splitio/ios-client.git", from: "3.4.2"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "swift-provider", + name: "SplitProvider", dependencies: [ .product(name: "OpenFeature", package: "swift-sdk"), .product(name: "Split", package: "ios-client"), ] ), .testTarget( - name: "swift-providerTests", - dependencies: ["swift-provider"] + name: "SplitProviderTests", + dependencies: ["SplitProvider"] ), ] ) diff --git a/Sources/SplitProvider/Errors.swift b/Sources/SplitProvider/Errors.swift new file mode 100644 index 0000000..3a5edf2 --- /dev/null +++ b/Sources/SplitProvider/Errors.swift @@ -0,0 +1,7 @@ +// Created by Martin Cardozo on 22/10/2025. + +internal enum Errors: Error { + case notImplemented + case missingInitContext + case missingInitData +} diff --git a/Sources/SplitProvider/EventHandler.swift b/Sources/SplitProvider/EventHandler.swift new file mode 100644 index 0000000..8616615 --- /dev/null +++ b/Sources/SplitProvider/EventHandler.swift @@ -0,0 +1,16 @@ +// Created by Martin Cardozo on 22/10/2025. + +import Combine +import OpenFeature + +internal final class EventHandler { // Used by Combine to propagate events + private let subject = PassthroughSubject() + + var publisher: AnyPublisher { + subject.eraseToAnyPublisher() + } + + func send(_ event: OpenFeature.ProviderEvent) { + subject.send(event) + } +} diff --git a/Sources/SplitProvider/SplitInitContext.swift b/Sources/SplitProvider/SplitInitContext.swift new file mode 100644 index 0000000..6a94666 --- /dev/null +++ b/Sources/SplitProvider/SplitInitContext.swift @@ -0,0 +1,36 @@ +// Created by Martin Cardozo on 22/10/2025. + +import OpenFeature + +internal struct SplitInitContext: OpenFeature.EvaluationContext { + let API_KEY: String + let USER_KEY: String + + func keySet() -> Set { + ["API_KEY", "USER_KEY"] + } + + func getTargetingKey() -> String { + USER_KEY + } + + func deepCopy() -> any OpenFeature.EvaluationContext { + SplitInitContext(API_KEY: API_KEY, USER_KEY: USER_KEY) + } + + func getValue(key: String) -> OpenFeature.Value? { + switch key { + case "API_KEY": return OpenFeature.Value.string(API_KEY) + case "USER_KEY": return OpenFeature.Value.string(USER_KEY) + default: return nil + } + } + + func asMap() -> [String : OpenFeature.Value] { + ["API_KEY": OpenFeature.Value.string(API_KEY), "USER_KEY": OpenFeature.Value.string(USER_KEY)] + } + + func asObjectMap() -> [String : AnyHashable?] { + ["API_KEY": API_KEY, "USER_KEY": USER_KEY] + } +} diff --git a/Sources/SplitProvider/SplitProvider.swift b/Sources/SplitProvider/SplitProvider.swift new file mode 100644 index 0000000..b2ac090 --- /dev/null +++ b/Sources/SplitProvider/SplitProvider.swift @@ -0,0 +1,93 @@ +import Combine +import Foundation +import OpenFeature +import Split + +public final class SplitProvider: FeatureProvider { + + // Split Components + private var splitClient: SplitClient? + private var splitFactory: SplitFactory? + private var splitContext: SplitInitContext? + private var splitClientConfig: SplitClientConfig? + + // Open Feature Components + public var hooks: [any OpenFeature.Hook] = [] + public var metadata: any OpenFeature.ProviderMetadata = SwiftProviderMetadata() + private let eventHandler = EventHandler() + + // MARK: Custom Initialization + public init(_ config: SplitClientConfig? = nil) { + self.splitClientConfig = config + } + + public func initialize(initialContext: (any OpenFeature.EvaluationContext)?) async throws { + + guard let initialContext = initialContext else { + eventHandler.send(.error(message: "Initialization context is missing for Split provider.")) + throw Errors.missingInitContext + } + + // 1. Unpack Context + let apiKeyValue = initialContext.getValue(key: "API_KEY")?.asString() + let userKeyValue = initialContext.getValue(key: "USER_KEY")?.asString() + guard let API_KEY = apiKeyValue, apiKeyValue != "", + let USER_KEY = userKeyValue, userKeyValue != "" + else { + eventHandler.send(.error(message: "Initialization data is missing for Split provider.")) + throw Errors.missingInitData + } + + // 2. Client setup + let context = SplitInitContext(API_KEY: API_KEY, USER_KEY: USER_KEY) + let key: Key = Key(matchingKey: USER_KEY) + let factoryBuilder = DefaultSplitFactoryBuilder() + factoryBuilder.setApiKey(API_KEY).setKey(key).setConfig(splitClientConfig ?? SplitClientConfig()) + splitFactory = factoryBuilder.build() + splitClient = splitFactory?.client + let manager = splitFactory?.manager + + splitClient?.on(event: .sdkReady) { [weak self] in + self?.eventHandler.send(ProviderEvent.ready) + } + } + + // MARK: Context Change + public func onContextSet(oldContext: (any OpenFeature.EvaluationContext)?, newContext: any OpenFeature.EvaluationContext) async throws { + throw Errors.notImplemented + } +} + +// MARK: Evaluation Methods +extension SplitProvider { + + public func getBooleanEvaluation(key: String, defaultValue: Bool, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation { + ProviderEvaluation(value: false) + } + + public func getStringEvaluation(key: String, defaultValue: String, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation { + ProviderEvaluation(value: splitClient?.getTreatment(key) ?? "CONTROL") + } + + public func getIntegerEvaluation(key: String, defaultValue: Int64, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation { + ProviderEvaluation(value: 1) + } + + public func getDoubleEvaluation(key: String, defaultValue: Double, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation { + ProviderEvaluation(value: 1.0) + } + + public func getObjectEvaluation(key: String, defaultValue: OpenFeature.Value, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation { + throw Errors.notImplemented + } + + public func observe() -> AnyPublisher { + eventHandler.publisher.eraseToAnyPublisher() + } +} + +// MARK: Open Feature Components +struct SwiftProviderMetadata: ProviderMetadata { + let name: String? = "Split" + let version: String? = "3.4.0" +} diff --git a/Sources/swift-provider/SplitProvider.swift b/Sources/swift-provider/SplitProvider.swift deleted file mode 100644 index a13cffe..0000000 --- a/Sources/swift-provider/SplitProvider.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Combine -import Foundation -import OpenFeature -import Split - -public class SplitProvider: FeatureProvider { - public var hooks: [any OpenFeature.Hook] = [] - - public var metadata: any OpenFeature.ProviderMetadata = SwiftProviderMetadata() - - public func initialize(initialContext: (any OpenFeature.EvaluationContext)?) async throws { - throw ProviderError.notImplemented - } - - public func onContextSet( - oldContext: (any OpenFeature.EvaluationContext)?, - newContext: any OpenFeature.EvaluationContext - ) async throws { - throw ProviderError.notImplemented - } - - public func getBooleanEvaluation( - key: String, defaultValue: Bool, context: (any OpenFeature.EvaluationContext)? - ) throws -> OpenFeature.ProviderEvaluation { - throw ProviderError.notImplemented - } - - public func getStringEvaluation( - key: String, defaultValue: String, context: (any OpenFeature.EvaluationContext)? - ) throws -> OpenFeature.ProviderEvaluation { - throw ProviderError.notImplemented - } - - public func getIntegerEvaluation( - key: String, defaultValue: Int64, context: (any OpenFeature.EvaluationContext)? - ) throws -> OpenFeature.ProviderEvaluation { - throw ProviderError.notImplemented - } - - public func getDoubleEvaluation( - key: String, defaultValue: Double, context: (any OpenFeature.EvaluationContext)? - ) throws -> OpenFeature.ProviderEvaluation { - throw ProviderError.notImplemented - } - - public func getObjectEvaluation( - key: String, defaultValue: OpenFeature.Value, context: (any OpenFeature.EvaluationContext)? - ) throws -> OpenFeature.ProviderEvaluation { - throw ProviderError.notImplemented - } - - public func observe() -> AnyPublisher { - return Empty().eraseToAnyPublisher() - } -} - -struct SwiftProviderMetadata: ProviderMetadata { - var name: String? = "Split" -} - -enum ProviderError: Error { - case notImplemented -} diff --git a/Tests/swift-providerTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift similarity index 90% rename from Tests/swift-providerTests/SplitProviderTests.swift rename to Tests/SplitProviderTests/SplitProviderTests.swift index 9147446..9a9af60 100644 --- a/Tests/swift-providerTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import swift_provider +@testable import SplitProvider @testable import OpenFeature final class SplitProviderTests: XCTestCase { From 7f3aaa4a407e7bdf2d4b0da755aaec8befca8864 Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Thu, 23 Oct 2025 13:30:02 -0300 Subject: [PATCH 02/15] Tests added --- Sources/SplitProvider/EventHandler.swift | 3 +- Sources/SplitProvider/SplitProvider.swift | 34 +-- .../SplitProviderMocks.swift | 142 +++++++++++++ .../SplitProviderTests.swift | 196 +++++++++++++++++- 4 files changed, 357 insertions(+), 18 deletions(-) create mode 100644 Tests/SplitProviderTests/SplitProviderMocks.swift diff --git a/Sources/SplitProvider/EventHandler.swift b/Sources/SplitProvider/EventHandler.swift index 8616615..66b2acc 100644 --- a/Sources/SplitProvider/EventHandler.swift +++ b/Sources/SplitProvider/EventHandler.swift @@ -3,7 +3,8 @@ import Combine import OpenFeature -internal final class EventHandler { // Used by Combine to propagate events +// Used by Combine to propagate events +internal final class EventHandler { private let subject = PassthroughSubject() var publisher: AnyPublisher { diff --git a/Sources/SplitProvider/SplitProvider.swift b/Sources/SplitProvider/SplitProvider.swift index b2ac090..5f0dfbe 100644 --- a/Sources/SplitProvider/SplitProvider.swift +++ b/Sources/SplitProvider/SplitProvider.swift @@ -1,20 +1,22 @@ +// Created by Martin Cardozo on 22/10/2025. + import Combine import Foundation import OpenFeature import Split -public final class SplitProvider: FeatureProvider { +public class SplitProvider: FeatureProvider { // Split Components - private var splitClient: SplitClient? - private var splitFactory: SplitFactory? - private var splitContext: SplitInitContext? - private var splitClientConfig: SplitClientConfig? + internal var splitClient: SplitClient? + internal var splitClientConfig: SplitClientConfig? + internal var factory: SplitFactory? // Open Feature Components public var hooks: [any OpenFeature.Hook] = [] - public var metadata: any OpenFeature.ProviderMetadata = SwiftProviderMetadata() + public var metadata: any OpenFeature.ProviderMetadata = SplitProviderMetadata() private let eventHandler = EventHandler() + private var splitContext: SplitInitContext? // MARK: Custom Initialization public init(_ config: SplitClientConfig? = nil) { @@ -39,17 +41,19 @@ public final class SplitProvider: FeatureProvider { } // 2. Client setup - let context = SplitInitContext(API_KEY: API_KEY, USER_KEY: USER_KEY) + splitContext = SplitInitContext(API_KEY: API_KEY, USER_KEY: USER_KEY) let key: Key = Key(matchingKey: USER_KEY) - let factoryBuilder = DefaultSplitFactoryBuilder() - factoryBuilder.setApiKey(API_KEY).setKey(key).setConfig(splitClientConfig ?? SplitClientConfig()) - splitFactory = factoryBuilder.build() - splitClient = splitFactory?.client - let manager = splitFactory?.manager + if factory == nil { factory = DefaultSplitFactoryBuilder().setApiKey(API_KEY).setKey(key).setConfig(splitClientConfig ?? SplitClientConfig()).build() } + + splitClient = factory?.client + + // 3. Wait for Ready signal + let semaphore = DispatchSemaphore(value: 0) splitClient?.on(event: .sdkReady) { [weak self] in - self?.eventHandler.send(ProviderEvent.ready) + semaphore.signal() } + semaphore.wait() } // MARK: Context Change @@ -86,8 +90,6 @@ extension SplitProvider { } } -// MARK: Open Feature Components -struct SwiftProviderMetadata: ProviderMetadata { +struct SplitProviderMetadata: ProviderMetadata { let name: String? = "Split" - let version: String? = "3.4.0" } diff --git a/Tests/SplitProviderTests/SplitProviderMocks.swift b/Tests/SplitProviderTests/SplitProviderMocks.swift new file mode 100644 index 0000000..48c57ea --- /dev/null +++ b/Tests/SplitProviderTests/SplitProviderMocks.swift @@ -0,0 +1,142 @@ +// Created by Martin Cardozo on 23/10/2025. + +import Split +import Foundation + +internal final class FactoryMock: SplitFactory { + + var client: any SplitClient = ClientMock() + var manager: any SplitManager = SplitManagerMock() + var userConsent: UserConsent = .granted + var version: String = "1" + + func client(key: Key) -> any SplitClient { ClientMock() } + func client(matchingKey: String) -> any SplitClient { ClientMock() } + func client(matchingKey: String, bucketingKey: String?) -> any SplitClient { ClientMock() } + func setUserConsent(enabled: Bool) { true } +} + +internal final class ClientMock: SplitClient { + var treatment = "Treatment" + + // MARK: Treatments + func getTreatment(_ split: String, attributes: [String : Any]?) -> String { + treatment + } + + func getTreatment(_ split: String) -> String { + treatment + } + + func getTreatments(splits: [String], attributes: [String : Any]?) -> [String : String] { + [treatment:treatment] + } + + func getTreatmentWithConfig(_ split: String) -> SplitResult { + SplitResult(treatment: treatment, config: nil) + } + + func getTreatmentWithConfig(_ split: String, attributes: [String : Any]?) -> SplitResult { + SplitResult(treatment: treatment, config: nil) + } + + func getTreatmentsWithConfig(splits: [String], attributes: [String : Any]?) -> [String : SplitResult] { + [treatment: SplitResult(treatment: treatment, config: nil)] + } + + func getTreatment(_ split: String, attributes: [String : Any]?, evaluationOptions: EvaluationOptions?) -> String { + treatment + } + + func getTreatments(splits: [String], attributes: [String : Any]?, evaluationOptions: EvaluationOptions?) -> [String : String] { + [treatment:treatment] + } + + func getTreatmentWithConfig(_ split: String, attributes: [String : Any]?, evaluationOptions: EvaluationOptions?) -> SplitResult { + SplitResult(treatment: treatment, config: nil) + } + + func getTreatmentsWithConfig(splits: [String], attributes: [String : Any]?, evaluationOptions: EvaluationOptions?) -> [String : SplitResult] { + [treatment: SplitResult(treatment: treatment, config: nil)] + } + + func getTreatmentsByFlagSet(_ flagSet: String, attributes: [String : Any]?) -> [String : String] { + [treatment:treatment] + } + + func getTreatmentsByFlagSets(_ flagSets: [String], attributes: [String : Any]?) -> [String : String] { + [treatment:treatment] + } + + func getTreatmentsWithConfigByFlagSet(_ flagSet: String, attributes: [String : Any]?) -> [String : SplitResult] { + [treatment: SplitResult(treatment: treatment, config: nil)] + } + + func getTreatmentsWithConfigByFlagSets(_ flagSets: [String], attributes: [String : Any]?) -> [String : SplitResult] { + [treatment: SplitResult(treatment: treatment, config: nil)] + } + + func getTreatmentsByFlagSet(_ flagSet: String, attributes: [String : Any]?, evaluationOptions: EvaluationOptions?) -> [String : String] { + [treatment: treatment] + } + + func getTreatmentsByFlagSets(_ flagSets: [String], attributes: [String : Any]?, evaluationOptions: EvaluationOptions?) -> [String : String] { + [treatment: treatment] + } + + func getTreatmentsWithConfigByFlagSet(_ flagSet: String, attributes: [String : Any]?, evaluationOptions: EvaluationOptions?) -> [String : SplitResult] { + [treatment: SplitResult(treatment: treatment, config: nil)] + } + + func getTreatmentsWithConfigByFlagSets(_ flagSets: [String], attributes: [String : Any]?, evaluationOptions: EvaluationOptions?) -> [String : SplitResult] { + [treatment: SplitResult(treatment: treatment, config: nil)] + } + + // MARK: Events + func on(event: SplitEvent, execute action: @escaping SplitAction) { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + action() + } + } + + func on(event: SplitEvent, runInBackground: Bool, execute action: @escaping SplitAction) { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + action() + } + } + + func on(event: SplitEvent, queue: DispatchQueue, execute action: @escaping SplitAction) { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + action() + } + } + + // MARK: Attributes + func setAttribute(name: String, value: Any) -> Bool { true } + func getAttribute(name: String) -> Any? { nil } + func setAttributes(_ values: [String : Any]) -> Bool { true} + func getAttributes() -> [String : Any]? { nil } + func removeAttribute(name: String) -> Bool { true } + func clearAttributes() -> Bool { true } + + // MARK: Lifecycle + func flush() {} + func destroy() {} + func destroy(completion: (() -> Void)?) {} + + // MARK: Track + func track(trafficType: String, eventType: String, properties: [String : Any]?) -> Bool { true } + func track(trafficType: String, eventType: String, value: Double, properties: [String : Any]?) -> Bool { true } + func track(eventType: String, properties: [String : Any]?) -> Bool { true } + func track(eventType: String, value: Double, properties: [String : Any]?) -> Bool { true } + func track(trafficType: String, eventType: String) -> Bool { true } + func track(trafficType: String, eventType: String, value: Double) -> Bool { true } + func track(eventType: String) -> Bool { true } + func track(eventType: String, value: Double) -> Bool { true } +} + +internal final class SplitManagerMock: SplitManager { + var splits: [SplitView] = [] + var splitNames: [String] = [] + func split(featureName: String) -> SplitView? { nil } +} diff --git a/Tests/SplitProviderTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift index 9a9af60..8c913d0 100644 --- a/Tests/SplitProviderTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -1,13 +1,207 @@ +// Created by Martin Cardozo on 22/10/2025. + import XCTest +import Combine +import Foundation @testable import SplitProvider @testable import OpenFeature +@testable import Split final class SplitProviderTests: XCTestCase { - func testSplitProviderImplementsFeatureProvider() throws { + + private var provider: SplitProvider! + private var providerCancellable: AnyCancellable? + + private let eventHandler = OpenFeature.EventHandler() + + override func setUp() {} + + override func tearDown() { + providerCancellable?.cancel() + } + + func testSplitProviderImplementsFeatureProvider() { XCTAssertTrue(SplitProvider() is FeatureProvider) } func testNameIsCorrect() { XCTAssertTrue(SplitProvider().metadata.name == "Split") } + + func testCorrectInitialization() { + + let readyExp = expectation(description: "SDK Ready") + let openFeatureExp = expectation(description: "OpenFeature Ready") + let nonErrorExp = expectation(description: "There should be no errors") + nonErrorExp.isInverted = true + + // Setup events observer + providerCancellable = OpenFeatureAPI.shared.observe().sink { event in + switch event { + case .ready: + readyExp.fulfill() + case .error(_): + nonErrorExp.fulfill() + break + default: + break + } + } + + let context = SplitInitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "martin") + provider = SplitProvider() + provider.factory = FactoryMock() + + // Kickoff Provider + Task { + await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: context) + openFeatureExp.fulfill() + } + + wait(for: [readyExp, openFeatureExp, nonErrorExp], timeout: 4) + } + + func testMissingApiKey() { + + let openFeatureExp = expectation(description: "OpenFeature Ready") + var errorFired = false + + // Setup events observer + providerCancellable = OpenFeatureAPI.shared.observe().sink { event in + switch event { + case .ready: + break + case .error(let message): + if message.message == "Initialization data is missing for Split provider." { + errorFired = true + } + break + default: + break + } + } + + let context = SplitInitContext(API_KEY: "", USER_KEY: "martin") + provider = SplitProvider() + provider.factory = FactoryMock() + + // Kickoff Provider + Task { + await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: context) + openFeatureExp.fulfill() + } + + wait(for: [openFeatureExp], timeout: 5) + XCTAssertTrue(errorFired) + } + + func testMissingUserKey() { + + let openFeatureExp = expectation(description: "OpenFeature Ready") + var errorFired = false + + // Setup events observer + providerCancellable = OpenFeatureAPI.shared.observe().sink { event in + switch event { + case .ready: + break + case .error(let message): + if message.message == "Initialization data is missing for Split provider." { + errorFired = true + } + break + default: + break + } + } + + let context = SplitInitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "") + provider = SplitProvider() + provider.factory = FactoryMock() + + // Kickoff Provider + Task { + await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: context) + openFeatureExp.fulfill() + } + + wait(for: [openFeatureExp], timeout: 5) + XCTAssertTrue(errorFired) + } + + func testMissingInitContext() async { + + let openFeatureExp = expectation(description: "OpenFeature Ready") + var errorFired = false + + provider = SplitProvider() + + // Setup events observer + providerCancellable = OpenFeatureAPI.shared.observe().sink { [weak self] event in + switch event { + case .ready: + self?.eval() + case .error(let message): + if message.message == "Initialization context is missing for Split provider." { + errorFired = true + } + default: + break + } + } + + provider = SplitProvider() + provider.factory = FactoryMock() + + // Kickoff Provider + Task { + await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: nil) + openFeatureExp.fulfill() + } + + wait(for: [openFeatureExp], timeout: 5) + XCTAssertTrue(errorFired) + } + + func testInitializationWithConfig() async { + + let openFeatureExp = expectation(description: "OpenFeature Ready") + + // Config if needed + let context = SplitInitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "martin") + let config = SplitClientConfig() + config.logLevel = .verbose + + provider = SplitProvider(config) + + // Setup events observer + providerCancellable = OpenFeatureAPI.shared.observe().sink { event in + switch event { + case .ready: + break + case .error(let message): + break + default: + break + } + } + + // Kickoff Provider + Task { + await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: nil) + openFeatureExp.fulfill() + } + + wait(for: [openFeatureExp], timeout: 5) + XCTAssertEqual(provider.splitClientConfig?.logLevel, .verbose) + } + + fileprivate func eval() { + do { + let eval = try provider.getStringEvaluation(key: "mauro-test-flag", defaultValue: "", context: nil) + print("Flag value:", eval.value) + } catch { + print("Provider error:", error) + } + } } From 89ab4af6a49d905cd99c4454565c574b488f92aa Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Thu, 23 Oct 2025 13:32:51 -0300 Subject: [PATCH 03/15] Parameterized eval function --- Tests/SplitProviderTests/SplitProviderTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SplitProviderTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift index 8c913d0..18446d1 100644 --- a/Tests/SplitProviderTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -196,9 +196,9 @@ final class SplitProviderTests: XCTestCase { XCTAssertEqual(provider.splitClientConfig?.logLevel, .verbose) } - fileprivate func eval() { + fileprivate func eval(_ flag: String) { do { - let eval = try provider.getStringEvaluation(key: "mauro-test-flag", defaultValue: "", context: nil) + let eval = try provider.getStringEvaluation(key: flag, defaultValue: "", context: nil) print("Flag value:", eval.value) } catch { print("Provider error:", error) From 2bcfa969ed15a003b385e30b7dd9e6cdc7f38b6a Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Thu, 23 Oct 2025 14:33:44 -0300 Subject: [PATCH 04/15] Tests now based on error codes and not messages --- Sources/SplitProvider/Errors.swift | 4 ++-- Sources/SplitProvider/SplitProvider.swift | 8 ++++---- Tests/SplitProviderTests/SplitProviderTests.swift | 14 +++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Sources/SplitProvider/Errors.swift b/Sources/SplitProvider/Errors.swift index 3a5edf2..2682cc9 100644 --- a/Sources/SplitProvider/Errors.swift +++ b/Sources/SplitProvider/Errors.swift @@ -2,6 +2,6 @@ internal enum Errors: Error { case notImplemented - case missingInitContext - case missingInitData + case missingInitContext(errorCode: Int) + case missingInitData(errorCode: Int) } diff --git a/Sources/SplitProvider/SplitProvider.swift b/Sources/SplitProvider/SplitProvider.swift index 5f0dfbe..ffa4360 100644 --- a/Sources/SplitProvider/SplitProvider.swift +++ b/Sources/SplitProvider/SplitProvider.swift @@ -26,8 +26,8 @@ public class SplitProvider: FeatureProvider { public func initialize(initialContext: (any OpenFeature.EvaluationContext)?) async throws { guard let initialContext = initialContext else { - eventHandler.send(.error(message: "Initialization context is missing for Split provider.")) - throw Errors.missingInitContext + eventHandler.send(.error(errorCode: ErrorCode(rawValue: 1) , message: "Initialization context is missing for Split provider.")) + throw Errors.missingInitContext(errorCode: 1) } // 1. Unpack Context @@ -36,8 +36,8 @@ public class SplitProvider: FeatureProvider { guard let API_KEY = apiKeyValue, apiKeyValue != "", let USER_KEY = userKeyValue, userKeyValue != "" else { - eventHandler.send(.error(message: "Initialization data is missing for Split provider.")) - throw Errors.missingInitData + eventHandler.send(.error(errorCode: ErrorCode(rawValue: 2) , message: "Initialization data is missing for Split provider.")) + throw Errors.missingInitData(errorCode: 2) } // 2. Client setup diff --git a/Tests/SplitProviderTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift index 18446d1..7e19d1c 100644 --- a/Tests/SplitProviderTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -72,7 +72,7 @@ final class SplitProviderTests: XCTestCase { case .ready: break case .error(let message): - if message.message == "Initialization data is missing for Split provider." { + if message.errorCode?.rawValue == 2 { errorFired = true } break @@ -92,7 +92,7 @@ final class SplitProviderTests: XCTestCase { } wait(for: [openFeatureExp], timeout: 5) - XCTAssertTrue(errorFired) + XCTAssertTrue(errorFired, "If there is no API key, an error should be fired") } func testMissingUserKey() { @@ -106,7 +106,7 @@ final class SplitProviderTests: XCTestCase { case .ready: break case .error(let message): - if message.message == "Initialization data is missing for Split provider." { + if message.errorCode?.rawValue == 2 { errorFired = true } break @@ -126,7 +126,7 @@ final class SplitProviderTests: XCTestCase { } wait(for: [openFeatureExp], timeout: 5) - XCTAssertTrue(errorFired) + XCTAssertTrue(errorFired, "If there is no API key, an error should be fired") } func testMissingInitContext() async { @@ -140,9 +140,9 @@ final class SplitProviderTests: XCTestCase { providerCancellable = OpenFeatureAPI.shared.observe().sink { [weak self] event in switch event { case .ready: - self?.eval() + self?.eval("mauro-test-flag") case .error(let message): - if message.message == "Initialization context is missing for Split provider." { + if message.errorCode?.rawValue == 1 { errorFired = true } default: @@ -160,7 +160,7 @@ final class SplitProviderTests: XCTestCase { } wait(for: [openFeatureExp], timeout: 5) - XCTAssertTrue(errorFired) + XCTAssertTrue(errorFired, "If there is no API key, an error should be fired") } func testInitializationWithConfig() async { From 824aa4f426321ddfef4624575b18d116a7fc682c Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Thu, 23 Oct 2025 14:36:51 -0300 Subject: [PATCH 05/15] Errors now have fixed codes --- Sources/SplitProvider/Errors.swift | 4 ++-- Sources/SplitProvider/SplitProvider.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SplitProvider/Errors.swift b/Sources/SplitProvider/Errors.swift index 2682cc9..a7b3207 100644 --- a/Sources/SplitProvider/Errors.swift +++ b/Sources/SplitProvider/Errors.swift @@ -2,6 +2,6 @@ internal enum Errors: Error { case notImplemented - case missingInitContext(errorCode: Int) - case missingInitData(errorCode: Int) + case missingInitContext(errorCode: Int = 1) + case missingInitData(errorCode: Int = 2) } diff --git a/Sources/SplitProvider/SplitProvider.swift b/Sources/SplitProvider/SplitProvider.swift index ffa4360..f527ce1 100644 --- a/Sources/SplitProvider/SplitProvider.swift +++ b/Sources/SplitProvider/SplitProvider.swift @@ -27,7 +27,7 @@ public class SplitProvider: FeatureProvider { guard let initialContext = initialContext else { eventHandler.send(.error(errorCode: ErrorCode(rawValue: 1) , message: "Initialization context is missing for Split provider.")) - throw Errors.missingInitContext(errorCode: 1) + throw Errors.missingInitContext() } // 1. Unpack Context @@ -37,7 +37,7 @@ public class SplitProvider: FeatureProvider { let USER_KEY = userKeyValue, userKeyValue != "" else { eventHandler.send(.error(errorCode: ErrorCode(rawValue: 2) , message: "Initialization data is missing for Split provider.")) - throw Errors.missingInitData(errorCode: 2) + throw Errors.missingInitData() } // 2. Client setup From 1ba3db591e01d2bec243501ab17fcc668bdcdb2a Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Thu, 23 Oct 2025 14:39:22 -0300 Subject: [PATCH 06/15] SDK Ready event updated to async context --- Sources/SplitProvider/SplitProvider.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SplitProvider/SplitProvider.swift b/Sources/SplitProvider/SplitProvider.swift index f527ce1..4f97458 100644 --- a/Sources/SplitProvider/SplitProvider.swift +++ b/Sources/SplitProvider/SplitProvider.swift @@ -49,11 +49,11 @@ public class SplitProvider: FeatureProvider { splitClient = factory?.client // 3. Wait for Ready signal - let semaphore = DispatchSemaphore(value: 0) - splitClient?.on(event: .sdkReady) { [weak self] in - semaphore.signal() + await withCheckedContinuation { continuation in + splitClient?.on(event: .sdkReady) { + continuation.resume() + } } - semaphore.wait() } // MARK: Context Change From 7e19599ea45efccc483c72780eae24b36f6353d3 Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Thu, 23 Oct 2025 14:40:06 -0300 Subject: [PATCH 07/15] Typo on test message --- Tests/SplitProviderTests/SplitProviderTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SplitProviderTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift index 7e19d1c..b82f28c 100644 --- a/Tests/SplitProviderTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -126,7 +126,7 @@ final class SplitProviderTests: XCTestCase { } wait(for: [openFeatureExp], timeout: 5) - XCTAssertTrue(errorFired, "If there is no API key, an error should be fired") + XCTAssertTrue(errorFired, "If there is no User key, an error should be fired") } func testMissingInitContext() async { From 909ff5ca35cda358b81e3fb5faf50900c04d2b30 Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Thu, 23 Oct 2025 14:40:24 -0300 Subject: [PATCH 08/15] Typo on test message --- Tests/SplitProviderTests/SplitProviderTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SplitProviderTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift index b82f28c..24bb3c9 100644 --- a/Tests/SplitProviderTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -160,7 +160,7 @@ final class SplitProviderTests: XCTestCase { } wait(for: [openFeatureExp], timeout: 5) - XCTAssertTrue(errorFired, "If there is no API key, an error should be fired") + XCTAssertTrue(errorFired, "If there is no initialContext, an error should be fired") } func testInitializationWithConfig() async { From 725536d5805d64f0bffd2c3551788e39db88a0dc Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Thu, 23 Oct 2025 14:40:57 -0300 Subject: [PATCH 09/15] Message on test added --- Tests/SplitProviderTests/SplitProviderTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SplitProviderTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift index 24bb3c9..1982ce9 100644 --- a/Tests/SplitProviderTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -193,7 +193,7 @@ final class SplitProviderTests: XCTestCase { } wait(for: [openFeatureExp], timeout: 5) - XCTAssertEqual(provider.splitClientConfig?.logLevel, .verbose) + XCTAssertEqual(provider.splitClientConfig?.logLevel, .verbose, "SplitConfig should be correctly propagated") } fileprivate func eval(_ flag: String) { From 574111408a64772fc75b979505158be4575732b5 Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Thu, 23 Oct 2025 14:41:40 -0300 Subject: [PATCH 10/15] Removed unnecessary async on test methods --- Tests/SplitProviderTests/SplitProviderTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SplitProviderTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift index 1982ce9..022d56f 100644 --- a/Tests/SplitProviderTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -129,7 +129,7 @@ final class SplitProviderTests: XCTestCase { XCTAssertTrue(errorFired, "If there is no User key, an error should be fired") } - func testMissingInitContext() async { + func testMissingInitContext() { let openFeatureExp = expectation(description: "OpenFeature Ready") var errorFired = false @@ -163,7 +163,7 @@ final class SplitProviderTests: XCTestCase { XCTAssertTrue(errorFired, "If there is no initialContext, an error should be fired") } - func testInitializationWithConfig() async { + func testInitializationWithConfig() { let openFeatureExp = expectation(description: "OpenFeature Ready") From 60112a604d8c91a73bd578093f90ec67b7b15f7f Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Thu, 23 Oct 2025 20:37:27 -0300 Subject: [PATCH 11/15] Testing Yaml --- Tests/SplitProviderTests/SplitProviderTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SplitProviderTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift index 022d56f..5e3e9d6 100644 --- a/Tests/SplitProviderTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -21,7 +21,7 @@ final class SplitProviderTests: XCTestCase { } func testSplitProviderImplementsFeatureProvider() { - XCTAssertTrue(SplitProvider() is FeatureProvider) + XCTAssertTrue(SplitProvider() is FeatureProvider) } func testNameIsCorrect() { From ed3cda57a1304ea8aebe335e3b0649b5df7b201e Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Fri, 24 Oct 2025 15:20:08 -0300 Subject: [PATCH 12/15] Events are processed independently. New tests for InitContext. Errors and contants improved. --- Sources/SplitProvider/Constants.swift | 8 +++ Sources/SplitProvider/Errors.swift | 7 -- Sources/SplitProvider/InitContext.swift | 36 ++++++++++ Sources/SplitProvider/SplitErrors.swift | 8 +++ Sources/SplitProvider/SplitInitContext.swift | 36 ---------- Sources/SplitProvider/SplitProvider.swift | 62 ++++++++++------ .../SplitProviderTests/InitContextTests.swift | 52 ++++++++++++++ .../SplitProviderMocks.swift | 57 +++++++++++---- .../SplitProviderTests.swift | 71 +++++++++++++------ 9 files changed, 235 insertions(+), 102 deletions(-) create mode 100644 Sources/SplitProvider/Constants.swift delete mode 100644 Sources/SplitProvider/Errors.swift create mode 100644 Sources/SplitProvider/InitContext.swift create mode 100644 Sources/SplitProvider/SplitErrors.swift delete mode 100644 Sources/SplitProvider/SplitInitContext.swift create mode 100644 Tests/SplitProviderTests/InitContextTests.swift diff --git a/Sources/SplitProvider/Constants.swift b/Sources/SplitProvider/Constants.swift new file mode 100644 index 0000000..c723e06 --- /dev/null +++ b/Sources/SplitProvider/Constants.swift @@ -0,0 +1,8 @@ +// Created by Martin Cardozo on 24/10/2025. + +internal enum Constants: String { + case API_KEY = "API_KEY" + case USER_KEY = "USER_KEY" + case CONTROL = "CONTROL" + case PROVIDER_NAME = "Split" +} diff --git a/Sources/SplitProvider/Errors.swift b/Sources/SplitProvider/Errors.swift deleted file mode 100644 index a7b3207..0000000 --- a/Sources/SplitProvider/Errors.swift +++ /dev/null @@ -1,7 +0,0 @@ -// Created by Martin Cardozo on 22/10/2025. - -internal enum Errors: Error { - case notImplemented - case missingInitContext(errorCode: Int = 1) - case missingInitData(errorCode: Int = 2) -} diff --git a/Sources/SplitProvider/InitContext.swift b/Sources/SplitProvider/InitContext.swift new file mode 100644 index 0000000..c3632f1 --- /dev/null +++ b/Sources/SplitProvider/InitContext.swift @@ -0,0 +1,36 @@ +// Created by Martin Cardozo on 22/10/2025. + +import OpenFeature + +internal struct InitContext: OpenFeature.EvaluationContext { + let API_KEY: String + let USER_KEY: String + + func keySet() -> Set { + [Constants.API_KEY.rawValue, Constants.USER_KEY.rawValue] + } + + func getTargetingKey() -> String { + USER_KEY + } + + func deepCopy() -> any OpenFeature.EvaluationContext { + InitContext(API_KEY: API_KEY, USER_KEY: USER_KEY) + } + + func getValue(key: String) -> OpenFeature.Value? { + switch key { + case Constants.API_KEY.rawValue: return OpenFeature.Value.string(API_KEY) + case Constants.USER_KEY.rawValue: return OpenFeature.Value.string(USER_KEY) + default: return nil + } + } + + func asMap() -> [String : OpenFeature.Value] { + [Constants.API_KEY.rawValue: OpenFeature.Value.string(API_KEY), Constants.USER_KEY.rawValue: OpenFeature.Value.string(USER_KEY)] + } + + func asObjectMap() -> [String : AnyHashable?] { + [Constants.API_KEY.rawValue: API_KEY, Constants.USER_KEY.rawValue: USER_KEY] + } +} diff --git a/Sources/SplitProvider/SplitErrors.swift b/Sources/SplitProvider/SplitErrors.swift new file mode 100644 index 0000000..818f539 --- /dev/null +++ b/Sources/SplitProvider/SplitErrors.swift @@ -0,0 +1,8 @@ +// Created by Martin Cardozo on 22/10/2025. + +public enum SplitError: Int, Error { + case notImplemented + case missingInitContext + case missingInitData + case timeout +} diff --git a/Sources/SplitProvider/SplitInitContext.swift b/Sources/SplitProvider/SplitInitContext.swift deleted file mode 100644 index 6a94666..0000000 --- a/Sources/SplitProvider/SplitInitContext.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Created by Martin Cardozo on 22/10/2025. - -import OpenFeature - -internal struct SplitInitContext: OpenFeature.EvaluationContext { - let API_KEY: String - let USER_KEY: String - - func keySet() -> Set { - ["API_KEY", "USER_KEY"] - } - - func getTargetingKey() -> String { - USER_KEY - } - - func deepCopy() -> any OpenFeature.EvaluationContext { - SplitInitContext(API_KEY: API_KEY, USER_KEY: USER_KEY) - } - - func getValue(key: String) -> OpenFeature.Value? { - switch key { - case "API_KEY": return OpenFeature.Value.string(API_KEY) - case "USER_KEY": return OpenFeature.Value.string(USER_KEY) - default: return nil - } - } - - func asMap() -> [String : OpenFeature.Value] { - ["API_KEY": OpenFeature.Value.string(API_KEY), "USER_KEY": OpenFeature.Value.string(USER_KEY)] - } - - func asObjectMap() -> [String : AnyHashable?] { - ["API_KEY": API_KEY, "USER_KEY": USER_KEY] - } -} diff --git a/Sources/SplitProvider/SplitProvider.swift b/Sources/SplitProvider/SplitProvider.swift index 4f97458..376a85a 100644 --- a/Sources/SplitProvider/SplitProvider.swift +++ b/Sources/SplitProvider/SplitProvider.swift @@ -16,49 +16,65 @@ public class SplitProvider: FeatureProvider { public var hooks: [any OpenFeature.Hook] = [] public var metadata: any OpenFeature.ProviderMetadata = SplitProviderMetadata() private let eventHandler = EventHandler() - private var splitContext: SplitInitContext? + private var splitContext: InitContext? // MARK: Custom Initialization public init(_ config: SplitClientConfig? = nil) { - self.splitClientConfig = config + splitClientConfig = config } public func initialize(initialContext: (any OpenFeature.EvaluationContext)?) async throws { guard let initialContext = initialContext else { - eventHandler.send(.error(errorCode: ErrorCode(rawValue: 1) , message: "Initialization context is missing for Split provider.")) - throw Errors.missingInitContext() + eventHandler.send(.error(errorCode: .invalidContext, message: "Initialization context is missing for Split provider.")) + throw SplitError.missingInitContext } // 1. Unpack Context - let apiKeyValue = initialContext.getValue(key: "API_KEY")?.asString() - let userKeyValue = initialContext.getValue(key: "USER_KEY")?.asString() - guard let API_KEY = apiKeyValue, apiKeyValue != "", - let USER_KEY = userKeyValue, userKeyValue != "" - else { - eventHandler.send(.error(errorCode: ErrorCode(rawValue: 2) , message: "Initialization data is missing for Split provider.")) - throw Errors.missingInitData() + let apiKeyValue = initialContext.getValue(key: Constants.API_KEY.rawValue)?.asString() + let userKeyValue = initialContext.getValue(key: Constants.USER_KEY.rawValue)?.asString() + guard let API_KEY = apiKeyValue, apiKeyValue != "" else { + eventHandler.send(.error(errorCode: .invalidContext, message: "Initialization data is missing for Split provider.")) + throw SplitError.missingInitData + } + guard let USER_KEY = userKeyValue, userKeyValue != "" else { + eventHandler.send(.error(errorCode: .targetingKeyMissing, message: "Initialization data is missing for Split provider.")) + throw SplitError.missingInitData } // 2. Client setup - splitContext = SplitInitContext(API_KEY: API_KEY, USER_KEY: USER_KEY) + splitContext = InitContext(API_KEY: API_KEY, USER_KEY: USER_KEY) let key: Key = Key(matchingKey: USER_KEY) - - if factory == nil { factory = DefaultSplitFactoryBuilder().setApiKey(API_KEY).setKey(key).setConfig(splitClientConfig ?? SplitClientConfig()).build() } - + if factory == nil { + factory = DefaultSplitFactoryBuilder().setApiKey(API_KEY).setKey(key).setConfig(splitClientConfig ?? SplitClientConfig()).build() + } splitClient = factory?.client - // 3. Wait for Ready signal - await withCheckedContinuation { continuation in - splitClient?.on(event: .sdkReady) { - continuation.resume() + // 3. Wait for SDK + await withCheckedContinuation { (continuation: CheckedContinuation) in + var didResume = false + + // Avoid crash by multiple countinuations + func resumeOnce(error: Bool = false) { + guard !didResume else { return } + didResume = true + + if error { + eventHandler.send(.error(errorCode: .general, message: "Provider timed out")) + } else { + continuation.resume() + } } + + splitClient?.on(event: .sdkReady) { resumeOnce() } + splitClient?.on(event: .sdkReadyFromCache) { resumeOnce() } + splitClient?.on(event: .sdkReadyTimedOut) { resumeOnce(error: true) } } } // MARK: Context Change public func onContextSet(oldContext: (any OpenFeature.EvaluationContext)?, newContext: any OpenFeature.EvaluationContext) async throws { - throw Errors.notImplemented + throw SplitError.notImplemented } } @@ -70,7 +86,7 @@ extension SplitProvider { } public func getStringEvaluation(key: String, defaultValue: String, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation { - ProviderEvaluation(value: splitClient?.getTreatment(key) ?? "CONTROL") + ProviderEvaluation(value: splitClient?.getTreatment(key) ?? Constants.CONTROL.rawValue) } public func getIntegerEvaluation(key: String, defaultValue: Int64, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation { @@ -82,7 +98,7 @@ extension SplitProvider { } public func getObjectEvaluation(key: String, defaultValue: OpenFeature.Value, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation { - throw Errors.notImplemented + throw SplitError.notImplemented } public func observe() -> AnyPublisher { @@ -91,5 +107,5 @@ extension SplitProvider { } struct SplitProviderMetadata: ProviderMetadata { - let name: String? = "Split" + let name: String? = Constants.PROVIDER_NAME.rawValue } diff --git a/Tests/SplitProviderTests/InitContextTests.swift b/Tests/SplitProviderTests/InitContextTests.swift new file mode 100644 index 0000000..eb63873 --- /dev/null +++ b/Tests/SplitProviderTests/InitContextTests.swift @@ -0,0 +1,52 @@ +// Created by Martin Cardozo on 24/10/2025. + +import XCTest +import OpenFeature +@testable import SplitProvider + +final class InitContextTests: XCTestCase { + + var SUT: InitContext! + + override func setUp() { + SUT = InitContext(API_KEY: "skhjcgkjhfgasdhka", USER_KEY: "martin") + } + + override func tearDown() {} + + func testValues() { + XCTAssertEqual(SUT.getValue(key: Constants.API_KEY.rawValue)?.asString(), "skhjcgkjhfgasdhka") + XCTAssertEqual(SUT.getValue(key: Constants.USER_KEY.rawValue)?.asString(), "martin") + } + + func testKeySet() { + XCTAssertEqual(SUT.keySet(), [Constants.API_KEY.rawValue, Constants.USER_KEY.rawValue]) + } + + func testTargetingKey() { + XCTAssertEqual(SUT.getTargetingKey(), "martin") + } + + func testDeepCopy() { + let deepCopy = SUT.deepCopy() + + XCTAssertEqual(deepCopy.getValue(key: Constants.API_KEY.rawValue)?.asString(), "skhjcgkjhfgasdhka") + XCTAssertEqual(deepCopy.getValue(key: Constants.USER_KEY.rawValue)?.asString(), "martin") + } + + func testAsMap() { + let map = SUT.asMap() + + XCTAssertEqual(map[Constants.API_KEY.rawValue]?.asString(), "skhjcgkjhfgasdhka") + XCTAssertEqual(map[Constants.USER_KEY.rawValue]?.asString(), "martin") + } + + func testAsObjectMap() { + let asObjectMap = SUT.asObjectMap() + + XCTAssertEqual(asObjectMap[Constants.API_KEY.rawValue], "skhjcgkjhfgasdhka") + XCTAssertEqual(asObjectMap[Constants.USER_KEY.rawValue], "martin") + } +} + + diff --git a/Tests/SplitProviderTests/SplitProviderMocks.swift b/Tests/SplitProviderTests/SplitProviderMocks.swift index 48c57ea..6a87143 100644 --- a/Tests/SplitProviderTests/SplitProviderMocks.swift +++ b/Tests/SplitProviderTests/SplitProviderMocks.swift @@ -13,11 +13,21 @@ internal final class FactoryMock: SplitFactory { func client(key: Key) -> any SplitClient { ClientMock() } func client(matchingKey: String) -> any SplitClient { ClientMock() } func client(matchingKey: String, bucketingKey: String?) -> any SplitClient { ClientMock() } - func setUserConsent(enabled: Bool) { true } + func setUserConsent(enabled: Bool) {} + + // For testing + func getClient() -> ClientMock { + client as! ClientMock + } } internal final class ClientMock: SplitClient { var treatment = "Treatment" + var timeout = false + + init() { + print(":: Mock Client started") + } // MARK: Treatments func getTreatment(_ split: String, attributes: [String : Any]?) -> String { @@ -92,24 +102,45 @@ internal final class ClientMock: SplitClient { [treatment: SplitResult(treatment: treatment, config: nil)] } + var events: [SplitEvent: ()->()] = [:] + // MARK: Events func on(event: SplitEvent, execute action: @escaping SplitAction) { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - action() + + events[event] = action + + if timeout { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + print(":: Mock Client :: SDK_TIMEOUT") + self.events[.sdkReadyTimedOut]!() + } + return } - } - - func on(event: SplitEvent, runInBackground: Bool, execute action: @escaping SplitAction) { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - action() + + switch event { + case .sdkUpdated: + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + print(":: Mock Client :: SDK_UPDATED") + self.events[.sdkUpdated]!() + } + case .sdkReady: + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + print(":: Mock Client :: SDK_READY") + self.events[.sdkReady]!() + } + case .sdkReadyTimedOut: + return + case .sdkReadyFromCache: + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + print(":: Mock Client :: SDK_READY_FROM_CACHE") + self.events[.sdkReadyFromCache]!() + } } } - func on(event: SplitEvent, queue: DispatchQueue, execute action: @escaping SplitAction) { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - action() - } - } + func on(event: SplitEvent, runInBackground: Bool, execute action: @escaping SplitAction) {} + + func on(event: SplitEvent, queue: DispatchQueue, execute action: @escaping SplitAction) {} // MARK: Attributes func setAttribute(name: String, value: Any) -> Bool { true } diff --git a/Tests/SplitProviderTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift index 5e3e9d6..b3f8ad2 100644 --- a/Tests/SplitProviderTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -19,13 +19,9 @@ final class SplitProviderTests: XCTestCase { override func tearDown() { providerCancellable?.cancel() } - - func testSplitProviderImplementsFeatureProvider() { - XCTAssertTrue(SplitProvider() is FeatureProvider) - } func testNameIsCorrect() { - XCTAssertTrue(SplitProvider().metadata.name == "Split") + XCTAssertTrue(SplitProvider().metadata.name == Constants.PROVIDER_NAME.rawValue) } func testCorrectInitialization() { @@ -40,15 +36,14 @@ final class SplitProviderTests: XCTestCase { switch event { case .ready: readyExp.fulfill() - case .error(_): + case .error(let errorCode, _): nonErrorExp.fulfill() - break default: break } } - let context = SplitInitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "martin") + let context = InitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "martin") provider = SplitProvider() provider.factory = FactoryMock() @@ -71,17 +66,16 @@ final class SplitProviderTests: XCTestCase { switch event { case .ready: break - case .error(let message): - if message.errorCode?.rawValue == 2 { + case .error(let errorCode, _): + if errorCode == .invalidContext { errorFired = true } - break default: break } } - let context = SplitInitContext(API_KEY: "", USER_KEY: "martin") + let context = InitContext(API_KEY: "", USER_KEY: "martin") provider = SplitProvider() provider.factory = FactoryMock() @@ -105,17 +99,16 @@ final class SplitProviderTests: XCTestCase { switch event { case .ready: break - case .error(let message): - if message.errorCode?.rawValue == 2 { + case .error(let errorCode, _): + if errorCode == .targetingKeyMissing { errorFired = true } - break default: break } } - let context = SplitInitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "") + let context = InitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "") provider = SplitProvider() provider.factory = FactoryMock() @@ -141,8 +134,8 @@ final class SplitProviderTests: XCTestCase { switch event { case .ready: self?.eval("mauro-test-flag") - case .error(let message): - if message.errorCode?.rawValue == 1 { + case .error(let errorCode, _): + if errorCode == .invalidContext { errorFired = true } default: @@ -165,21 +158,23 @@ final class SplitProviderTests: XCTestCase { func testInitializationWithConfig() { + let readyExp = expectation(description: "SDK Ready") let openFeatureExp = expectation(description: "OpenFeature Ready") // Config if needed - let context = SplitInitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "martin") + let context = InitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "martin") let config = SplitClientConfig() config.logLevel = .verbose provider = SplitProvider(config) + provider.factory = FactoryMock() // Setup events observer providerCancellable = OpenFeatureAPI.shared.observe().sink { event in switch event { case .ready: - break - case .error(let message): + readyExp.fulfill() + case .error(_,_): break default: break @@ -188,13 +183,43 @@ final class SplitProviderTests: XCTestCase { // Kickoff Provider Task { - await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: nil) + await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: context) openFeatureExp.fulfill() } - wait(for: [openFeatureExp], timeout: 5) + wait(for: [openFeatureExp, readyExp], timeout: 5) XCTAssertEqual(provider.splitClientConfig?.logLevel, .verbose, "SplitConfig should be correctly propagated") } + + func testTimeOut() { + + let errorExp = expectation(description: "SDK should timeout") + + // Setup events observer + providerCancellable = OpenFeatureAPI.shared.observe().sink { event in + switch event { + case .ready: + break + case .error(let errorCode, let message): + if errorCode == .general && message == "Provider timed out" { + errorExp.fulfill() + } + default: + break + } + } + + let context = InitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "martin") + provider = SplitProvider() + let factory = FactoryMock() + factory.getClient().timeout = true // MARK: Fail point + provider.factory = factory + + // Kickoff Provider + Task { await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: context) } + + wait(for: [errorExp], timeout: 4) + } fileprivate func eval(_ flag: String) { do { From c09bc6b61518b3c4743249892db8fc7a130452e6 Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Mon, 27 Oct 2025 13:54:57 -0300 Subject: [PATCH 13/15] Changed inmutable property to came case --- Sources/SplitProvider/InitContext.swift | 16 ++++++++-------- Sources/SplitProvider/SplitProvider.swift | 2 +- Tests/SplitProviderTests/InitContextTests.swift | 2 +- .../SplitProviderTests/SplitProviderTests.swift | 10 +++++----- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/SplitProvider/InitContext.swift b/Sources/SplitProvider/InitContext.swift index c3632f1..1d1ef65 100644 --- a/Sources/SplitProvider/InitContext.swift +++ b/Sources/SplitProvider/InitContext.swift @@ -3,34 +3,34 @@ import OpenFeature internal struct InitContext: OpenFeature.EvaluationContext { - let API_KEY: String - let USER_KEY: String + let apiKey: String + let userKey: String func keySet() -> Set { [Constants.API_KEY.rawValue, Constants.USER_KEY.rawValue] } func getTargetingKey() -> String { - USER_KEY + userKey } func deepCopy() -> any OpenFeature.EvaluationContext { - InitContext(API_KEY: API_KEY, USER_KEY: USER_KEY) + InitContext(apiKey: apiKey, userKey: userKey) } func getValue(key: String) -> OpenFeature.Value? { switch key { - case Constants.API_KEY.rawValue: return OpenFeature.Value.string(API_KEY) - case Constants.USER_KEY.rawValue: return OpenFeature.Value.string(USER_KEY) + case Constants.API_KEY.rawValue: return OpenFeature.Value.string(apiKey) + case Constants.USER_KEY.rawValue: return OpenFeature.Value.string(userKey) default: return nil } } func asMap() -> [String : OpenFeature.Value] { - [Constants.API_KEY.rawValue: OpenFeature.Value.string(API_KEY), Constants.USER_KEY.rawValue: OpenFeature.Value.string(USER_KEY)] + [Constants.API_KEY.rawValue: OpenFeature.Value.string(apiKey), Constants.USER_KEY.rawValue: OpenFeature.Value.string(userKey)] } func asObjectMap() -> [String : AnyHashable?] { - [Constants.API_KEY.rawValue: API_KEY, Constants.USER_KEY.rawValue: USER_KEY] + [Constants.API_KEY.rawValue: apiKey, Constants.USER_KEY.rawValue: userKey] } } diff --git a/Sources/SplitProvider/SplitProvider.swift b/Sources/SplitProvider/SplitProvider.swift index 376a85a..354d714 100644 --- a/Sources/SplitProvider/SplitProvider.swift +++ b/Sources/SplitProvider/SplitProvider.swift @@ -43,7 +43,7 @@ public class SplitProvider: FeatureProvider { } // 2. Client setup - splitContext = InitContext(API_KEY: API_KEY, USER_KEY: USER_KEY) + splitContext = InitContext(apiKey: API_KEY, userKey: USER_KEY) let key: Key = Key(matchingKey: USER_KEY) if factory == nil { factory = DefaultSplitFactoryBuilder().setApiKey(API_KEY).setKey(key).setConfig(splitClientConfig ?? SplitClientConfig()).build() diff --git a/Tests/SplitProviderTests/InitContextTests.swift b/Tests/SplitProviderTests/InitContextTests.swift index eb63873..c245528 100644 --- a/Tests/SplitProviderTests/InitContextTests.swift +++ b/Tests/SplitProviderTests/InitContextTests.swift @@ -9,7 +9,7 @@ final class InitContextTests: XCTestCase { var SUT: InitContext! override func setUp() { - SUT = InitContext(API_KEY: "skhjcgkjhfgasdhka", USER_KEY: "martin") + SUT = InitContext(apiKey: "skhjcgkjhfgasdhka", userKey: "martin") } override func tearDown() {} diff --git a/Tests/SplitProviderTests/SplitProviderTests.swift b/Tests/SplitProviderTests/SplitProviderTests.swift index b3f8ad2..1008e4f 100644 --- a/Tests/SplitProviderTests/SplitProviderTests.swift +++ b/Tests/SplitProviderTests/SplitProviderTests.swift @@ -43,7 +43,7 @@ final class SplitProviderTests: XCTestCase { } } - let context = InitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "martin") + let context = InitContext(apiKey: "sofd75fo7w6ao576oshf567jshdkfrbk746", userKey: "martin") provider = SplitProvider() provider.factory = FactoryMock() @@ -75,7 +75,7 @@ final class SplitProviderTests: XCTestCase { } } - let context = InitContext(API_KEY: "", USER_KEY: "martin") + let context = InitContext(apiKey: "", userKey: "martin") provider = SplitProvider() provider.factory = FactoryMock() @@ -108,7 +108,7 @@ final class SplitProviderTests: XCTestCase { } } - let context = InitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "") + let context = InitContext(apiKey: "sofd75fo7w6ao576oshf567jshdkfrbk746", userKey: "") provider = SplitProvider() provider.factory = FactoryMock() @@ -162,7 +162,7 @@ final class SplitProviderTests: XCTestCase { let openFeatureExp = expectation(description: "OpenFeature Ready") // Config if needed - let context = InitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "martin") + let context = InitContext(apiKey: "sofd75fo7w6ao576oshf567jshdkfrbk746", userKey: "martin") let config = SplitClientConfig() config.logLevel = .verbose @@ -209,7 +209,7 @@ final class SplitProviderTests: XCTestCase { } } - let context = InitContext(API_KEY: "sofd75fo7w6ao576oshf567jshdkfrbk746", USER_KEY: "martin") + let context = InitContext(apiKey: "sofd75fo7w6ao576oshf567jshdkfrbk746", userKey: "martin") provider = SplitProvider() let factory = FactoryMock() factory.getClient().timeout = true // MARK: Fail point From cf0a3de0700a98c66148b9daec3da1d5c1abc93f Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Mon, 27 Oct 2025 13:59:22 -0300 Subject: [PATCH 14/15] Improving code with props on InitContetx --- Sources/SplitProvider/InitContext.swift | 26 ++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/Sources/SplitProvider/InitContext.swift b/Sources/SplitProvider/InitContext.swift index 1d1ef65..3279116 100644 --- a/Sources/SplitProvider/InitContext.swift +++ b/Sources/SplitProvider/InitContext.swift @@ -6,6 +6,13 @@ internal struct InitContext: OpenFeature.EvaluationContext { let apiKey: String let userKey: String + var props: [String: OpenFeature.Value] { + [ + "API_KEY": .string(apiKey), + "USER_KEY": .string(userKey) + ] + } + func keySet() -> Set { [Constants.API_KEY.rawValue, Constants.USER_KEY.rawValue] } @@ -19,18 +26,19 @@ internal struct InitContext: OpenFeature.EvaluationContext { } func getValue(key: String) -> OpenFeature.Value? { - switch key { - case Constants.API_KEY.rawValue: return OpenFeature.Value.string(apiKey) - case Constants.USER_KEY.rawValue: return OpenFeature.Value.string(userKey) - default: return nil - } + props[key] } - func asMap() -> [String : OpenFeature.Value] { - [Constants.API_KEY.rawValue: OpenFeature.Value.string(apiKey), Constants.USER_KEY.rawValue: OpenFeature.Value.string(userKey)] + func asMap() -> [String: OpenFeature.Value] { + props } - func asObjectMap() -> [String : AnyHashable?] { - [Constants.API_KEY.rawValue: apiKey, Constants.USER_KEY.rawValue: userKey] + func asObjectMap() -> [String: AnyHashable?] { + props.mapValues { value in + switch value { + case .string(let str): return str + default: return nil + } + } } } From 83ba27112962326dd93cadc1aa0b6478a1fe6fc2 Mon Sep 17 00:00:00 2001 From: Martin Cardozo Date: Mon, 27 Oct 2025 14:01:59 -0300 Subject: [PATCH 15/15] Testing GHA --- Sources/SplitProvider/InitContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SplitProvider/InitContext.swift b/Sources/SplitProvider/InitContext.swift index 3279116..35f3d79 100644 --- a/Sources/SplitProvider/InitContext.swift +++ b/Sources/SplitProvider/InitContext.swift @@ -41,4 +41,4 @@ internal struct InitContext: OpenFeature.EvaluationContext { } } } -} +}