diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..f7bb465 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,22 @@ +name: Swift-SDK +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Show Swift version + run: swift --version + + - name: Build + run: swift build + + - name: Run tests + run: swift test diff --git a/.gitignore b/.gitignore index 2950f9d..b58402e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,21 @@ -.DS_Store -.vscode/ -.idea/ -build/ -*.pbxuser !default.pbxuser -xcuserdata/ - -*.xcuserstate -*.xcscmblueprint - -*.hmap -*.ipa -*.dSYM.zip -*.dSYM - .build +.claude/ +.DS_Store +.idea/ .swiftpm +.vscode/ +*.dSYM +*.dSYM.zip +*.hmap +*.ipa +*.pbxuser +*.xcscmblueprint +*.xcuserstate +AUDIT_REPORT.md +build/ +COMPLETION_SUMMARY.md +FIXES_IMPLEMENTED.md Packages +xcuserdata/ diff --git a/Example/Example/ViewController.swift b/Example/Example/ViewController.swift index 6764253..e01a437 100644 --- a/Example/Example/ViewController.swift +++ b/Example/Example/ViewController.swift @@ -5,7 +5,7 @@ import UIKit class ViewController: UIViewController { private let button = UIButton() - private var sdk: ABSmartlySDK? + private var sdk: ABsmartlySDK? private var context: Context! override func viewDidLoad() { @@ -48,8 +48,8 @@ class ViewController: UIViewController { do { let client = try DefaultClient(config: clientConfig) - let sdkConfig = ABSmartlyConfig(client: client) - sdk = try ABSmartlySDK(config: sdkConfig) + let sdkConfig = ABsmartlyConfig(client: client) + sdk = try ABsmartlySDK(config: sdkConfig) } catch { print(error.localizedDescription) return @@ -57,8 +57,9 @@ class ViewController: UIViewController { let contextConfig = ContextConfig() contextConfig.refreshInterval = 5 + let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString contextConfig.setUnit( - unitType: "anonymous_id", uid: UIDevice.current.identifierForVendor!.uuidString + "1") + unitType: "anonymous_id", uid: deviceId + "1") self.button.addTarget(self, action: #selector(click), for: .touchUpInside) diff --git a/Package.resolved b/Package.resolved index 7d5a742..e8341b6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -10,6 +10,15 @@ "version": "6.17.0" } }, + { + "package": "swift-asn1", + "repositoryURL": "https://github.com/apple/swift-asn1.git", + "state": { + "branch": null, + "revision": "810496cf121e525d660cd0ea89a758740476b85f", + "version": "1.5.1" + } + }, { "package": "swift-atomics", "repositoryURL": "https://github.com/apple/swift-atomics.git", @@ -19,6 +28,15 @@ "version": "1.0.2" } }, + { + "package": "swift-crypto", + "repositoryURL": "https://github.com/apple/swift-crypto.git", + "state": { + "branch": null, + "revision": "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version": "3.15.1" + } + }, { "package": "SwiftyJSON", "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git", diff --git a/Package.swift b/Package.swift index c306e90..90fc1d7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.6 import PackageDescription let package = Package( @@ -17,12 +17,18 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.2")), .package(url: "https://github.com/mxcl/PromiseKit.git", .upToNextMajor(from: "6.8.4")), - .package(url: "https://github.com/SwiftyJSON/SwiftyJSON.git", .upToNextMajor(from: "5.0.0")) + .package(url: "https://github.com/SwiftyJSON/SwiftyJSON.git", .upToNextMajor(from: "5.0.0")), + .package(url: "https://github.com/apple/swift-crypto.git", .upToNextMajor(from: "3.0.0")) ], targets: [ .target( name: "ABSmartly", - dependencies: [.product(name: "Atomics", package: "swift-atomics"), "PromiseKit", "SwiftyJSON"], + dependencies: [ + .product(name: "Atomics", package: "swift-atomics"), + "PromiseKit", + "SwiftyJSON", + .product(name: "Crypto", package: "swift-crypto") + ], path: "Sources/ABSmartly"), .testTarget( name: "ABSmartlyTests", diff --git a/README.md b/README.md index 9efd8d4..c72c3d2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ -# A/B Smartly SDK +# A/B Smartly Swift SDK -A/B Smartly - Swift SDK +A/B Smartly - Swift SDK for iOS and macOS applications. ## Compatibility -The A/B Smartly Swift SDK is supported on macOS version 10.10 or later and iOS version 10 or later. +The A/B Smartly Swift SDK is supported on: +- iOS 10.0 or later +- macOS 10.10 or later + +| Platform | Support | Notes | +|-------------|------------|------------------------------------------------| +| iOS | iOS 10+ | Full support including UIDevice integration | +| macOS | 10.10+ | Full support | +| Swift | 5.0+ | Swift Package Manager and CocoaPods supported | ## Installation @@ -35,9 +43,11 @@ pod install ## Getting Started -Please follow the [installation](#installation) instructions before trying the following code: +Please follow the [installation](#installation) instructions before trying the following code. + +### Initialization -#### Initialization +This example assumes an API Key, an Application, and an Environment have been created in the A/B Smartly web console. Import the SDK into your application: @@ -45,73 +55,184 @@ Import the SDK into your application: import ABSmartly ``` +#### Recommended: Named Parameters + +Initialize the SDK using named parameters: -Initialize the client and the SDK ```swift -let sdk: ABSmartlySDK +let sdk: ABsmartlySDK do { - let clientConfig = ClientConfig( - apiKey: ProcessInfo.processInfo.environment["ABSMARTLY_API_KEY"] ?? "", - application: ProcessInfo.processInfo.environment["ABSMARTLY_APPLICATION"] ?? "", - endpoint: ProcessInfo.processInfo.environment["ABSMARTLY_ENDPOINT"] ?? "", - environment: ProcessInfo.processInfo.environment["ABSMARTLY_ENVIRONMENT"] ?? "")) - - let client = try DefaultClient(config: clientConfig) - let sdkConfig = ABSmartlyConfig(client: client) - sdk = try ABSmartlySDK(config: sdkConfig) + sdk = try ABsmartlySDK( + endpoint: "https://your-company.absmartly.io/v1", + apiKey: "YOUR-API-KEY", + application: "website", + environment: "production" + ) } catch { - print(error.localizedDescription) + print("Failed to initialize ABSmartly SDK: \(error.localizedDescription)") return } ``` -#### Creating a new Context +#### With Optional Parameters + ```swift -let contextConfig: ContextConfig = ContextConfig() -contextConfig.setUnit(unitType: "device_id", uid: UIDevice.current.identifierForVendor!.uuidString)) +let sdk = try ABsmartlySDK( + endpoint: "https://your-company.absmartly.io/v1", + apiKey: "YOUR-API-KEY", + application: "website", + environment: "production", + applicationVersion: "1.0.0", + timeout: 5.0, // Default: 3.0 seconds + retries: 3 // Default: 5 +) +``` + +#### Alternative: Using Configuration Objects + +For use cases with custom providers or handlers: + +```swift +let clientConfig = ClientConfig( + apiKey: "YOUR-API-KEY", + application: "website", + endpoint: "https://your-company.absmartly.io/v1", + environment: "production" +) + +let client = try DefaultClient(config: clientConfig) +let sdkConfig = ABsmartlyConfig(client: client) +let sdk = try ABsmartlySDK(config: sdkConfig) +``` + +**SDK Options** + +| Config | Type | Required? | Default | Description | +| :---------------------- | :-------------------------------- | :-------: | :---------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoint | `String` | ✅ | `nil` | The URL to your API endpoint. Most commonly `"https://your-company.absmartly.io/v1"` | +| apiKey | `String` | ✅ | `nil` | Your API key which can be found on the Web Console. | +| application | `String` | ✅ | `nil` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. | +| environment | `String` | ✅ | `nil` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | +| applicationVersion | `String` | ❌ | `"0"` | The version of your application. | +| timeout | `TimeInterval` | ❌ | `3.0` | Network request timeout in seconds. | +| retries | `UInt` | ❌ | `5` | Number of retry attempts for failed network requests. | +| contextEventLogger | `ContextEventLogger` | ❌ | `nil` | Callback to handle SDK events (ready, exposure, goal, etc.) | +| contextDataProvider | `ContextDataProvider` | ❌ | auto | Custom provider for context data (advanced usage) | +| contextEventHandler | `ContextEventHandler` | ❌ | auto | Custom handler for publishing events (advanced usage) | +| variableParser | `VariableParser` | ❌ | auto | Custom parser for variable values (advanced usage) | +| scheduler | `Scheduler` | ❌ | auto | Custom scheduler for async operations (advanced usage) | + +## Creating a New Context + +### Asynchronously (Recommended) + +```swift +let contextConfig = ContextConfig() +contextConfig.setUnit(unitType: "session_id", uid: "5ebf06d8cb5d8137290c4abb64155584fbdb64d8") let context = sdk.createContext(config: contextConfig) context.waitUntilReady().done { context in - print("context ready") + print("ABSmartly Context ready!") +}.catch { error in + print("Context failed to initialize: \(error.localizedDescription)") +} +``` + +### Using async/await (iOS 13+) + +```swift +let contextConfig = ContextConfig() +contextConfig.setUnit(unitType: "session_id", uid: "5ebf06d8cb5d8137290c4abb64155584fbdb64d8") + +let context = sdk.createContext(config: contextConfig) +do { + try await context.waitUntilReady() + print("ABSmartly Context ready!") +} catch { + print("Context failed to initialize: \(error.localizedDescription)") } ``` -#### Creating a new Context with pre-fetched data -When doing full-stack experimentation with A/B Smartly, we recommend creating a context only once on the server-side. -Creating a context involves a round-trip to the A/B Smartly event collector. -We can avoid repeating the round-trip on the client-side by sending the server-side data embedded with other application data. -Then we can initialize the A/B Smartly context directly with it. +### With Pre-fetched Data + +When doing full-stack experimentation with A/B Smartly, we recommend creating a context only once on the server-side. Creating a context involves a round-trip to the A/B Smartly event collector. We can avoid repeating the round-trip on the client-side by sending the server-side data embedded with other application data. Then we can initialize the A/B Smartly context directly with it. ```swift -let contextConfig: ContextConfig = ContextConfig() -contextConfig.setUnit(unitType: "device_id", uid: UIDevice.current.identifierForVendor!.uuidString) +let contextConfig = ContextConfig() +contextConfig.setUnit(unitType: "session_id", uid: "5ebf06d8cb5d8137290c4abb64155584fbdb64d8") + +let context = sdk.createContext(config: contextConfig) +try await context.waitUntilReady() + +let anotherContextConfig = ContextConfig() +anotherContextConfig.setUnit(unitType: "session_id", uid: "another-user-id") -let context = sdk.createContextWithData(config: anotherContextConfig, contextData: contextData) +let anotherContext = sdk.createContextWithData(config: anotherContextConfig, contextData: context.getContextData()) ``` -#### Setting extra units for a context -You can add additional units to a context by calling the `setUnit()` or the `setUnits()` method. -This method may be used for example, when a user logs in to your application, and you want to use the new unit type to the context. -Please note that **you cannot override an already set unit type** as that would be a change of identity, and will crash your application. In this case, you must create a new context instead. +### Refreshing the Context with Fresh Experiment Data + +For long-running contexts, the context is usually created once when the application is first started. However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. + +To mitigate this, we can use the `refreshInterval` property on the context config: + +```swift +let contextConfig = ContextConfig() +contextConfig.setUnit(unitType: "session_id", uid: "5ebf06d8cb5d8137290c4abb64155584fbdb64d8") +contextConfig.refreshInterval = 4 * 3600 // every 4 hours (in seconds) +``` + +Alternatively, the `refresh()` method can be called manually. The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `getTreatment()` is called again. + +```swift +context.refresh().done { + print("Context refreshed with latest experiment data") +}.catch { error in + print("Refresh failed: \(error.localizedDescription)") +} +``` + +### Setting Extra Units + +You can add additional units to a context by calling the `setUnit()` or `setUnits()` methods. This is useful when a user logs in to your application and you want to associate a new unit type with the context. + +Please note that **you cannot override an already set unit type** as that would be a change of identity. In this case, you must create a new context instead. + The `setUnit()` and `setUnits()` methods can be called before the context is ready. ```swift -context.setUnit(unitType: "db_user_id", uid: "1000013"); +context.setUnit(unitType: "db_user_id", uid: "1000013") context.setUnits([ "db_user_id": "1000013" -]); +]) ``` -#### Setting context attributes -The `setAttribute()` and `setAttributes()` methods can be called before the context is ready. +## Basic Usage + +### Selecting a Treatment + ```swift -context.setAttribute(name: "device", value: UIDevice.current.model) -context.setAttributes(["customer_age": "new_customer", "screen": "product"]) +let treatment = context.getTreatment("exp_test_experiment") +if treatment == 0 { + // user is in control group (variant 0) +} else { + // user is in treatment group +} ``` -#### Selecting a treatment +### Treatment Variables + ```swift -let treatment = context.getTreatment("exp_test_experiment") +let defaultButtonColor = "red" +let buttonColor = context.getVariableValue("button.color", defaultValue: defaultButtonColor) +``` + +### Peek at Treatment Variants + +Although generally not recommended, it is sometimes necessary to peek at a treatment or variable without triggering an exposure. The A/B Smartly SDK provides a `peekTreatment()` method for that. + +```swift +let treatment = context.peekTreatment("exp_test_experiment") if treatment == 0 { // user is in control group (variant 0) } else { @@ -119,140 +240,455 @@ if treatment == 0 { } ``` -#### Selecting a treatment variable +#### Peeking at Variables + ```swift -let variable = context.getVariableValue("my_variable", defaultValue: 10) +let color = context.peekVariableValue("colorGComponent", defaultValue: 255) ``` -#### Tracking a goal achievement -Goals are created in the A/B Smartly web console. +### Overriding Treatment Variants + +During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `setOverride()` and/or `setOverrides()` methods. + +The `setOverride()` and `setOverrides()` methods can be called before the context is ready. + ```swift -context.track("payment", properties: ["item_count": 1, "total_amount": 1999.99]) +context.setOverride(experimentName: "exp_test_experiment", variant: 1) // force variant 1 of treatment +context.setOverrides(["exp_test_experiment": 1, "exp_another_experiment": 0]) ``` -#### Publishing pending data -Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector, before proceeding. You can explicitly call the `publish()` method. +## Advanced + +### Context Attributes + +The `setAttribute()` and `setAttributes()` methods can be called before the context is ready. + ```swift -context.publish().done { - print("all pending events published") -} +context.setAttribute(name: "device", value: UIDevice.current.model) +context.setAttributes([ + "customer_age": "new_customer", + "screen": "product" +]) ``` -#### Finalizing -The `close()` methods will ensure all events have been published to the A/B Smartly collector, like `publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called. +### Tracking Goals + +Goals are created in the A/B Smartly web console. + ```swift -context.close().done { - print("context closed") -} +context.track("payment", properties: [ + "item_count": 1, + "total_amount": 1999.99 +]) ``` -#### Refreshing the context with fresh experiment data -For long-running contexts, the context is usually created once when the application is first reached. -However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. -To mitigate this, we can use the `setRefreshInterval()` method on the context config. +### Publishing Pending Data + +Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector before proceeding. You can explicitly call the `publish()` method. ```swift -let contextConfig: ContextConfig = ContextConfig() -contextConfig.setUnit(unitType: "device_id", uid: UIDevice.current.identifierForVendor!.uuidString) -contextConfig.refreshInterval = 4 * 3600; // every 4 hours +context.publish().done { + print("All pending events published") +}.catch { error in + print("Publish failed: \(error.localizedDescription)") +} ``` -Alternatively, the `refresh()` method can be called manually. +### Finalizing + +The `close()` method will ensure all events have been published to the A/B Smartly collector, like `publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called. ```swift -context.refresh().done { - print("refreshed") +context.close().done { + print("Context closed") +}.catch { error in + print("Close failed: \(error.localizedDescription)") } ``` +### Custom Event Logger + +The A/B Smartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context in the `ContextConfig`. -#### Using a custom Event Logger -The A/B Smartly SDK can be instantiated with an event logger used for all contexts. -In addition, an event logger can be specified when creating a particular context, in the `ContextConfig`. ```swift -// example implementation -public class CustomEventLogger : ContextEventLogger { +public class CustomEventLogger: ContextEventLogger { public func handleEvent(context: Context, event: ContextEventLoggerEvent) { switch event { case let .exposure(exposure): - print("exposed to experiment: \(exposure.name)") + print("Exposed to experiment: \(exposure.name)") case let .goal(goal): - print("goal tracked: \(goal.name)") + print("Goal tracked: \(goal.name)") case let .error(error): - print("error: ", error.localizedDescription) + print("Error: \(error.localizedDescription)") case let .publish(event): - break + print("Events published") case let .ready(data): - break + print("Context ready") case let .refresh(data): - break + print("Context refreshed") case .close: - break + print("Context closed") } } } +``` + +**Usage:** -// for all contexts, during sdk initialization -let absmartlyConfig = ABSmartlyConfig( - contextDataProvider: nil, - contextEventHandler: nil, - contextEventLogger: CustomEventLogger(), - variableParser: nil, - scheduler: nil, - client: client) +```swift +// For all contexts, during SDK initialization +let sdk = try ABsmartlySDK( + endpoint: "https://your-company.absmartly.io/v1", + apiKey: "YOUR-API-KEY", + application: "website", + environment: "production", + contextEventLogger: CustomEventLogger() +) // OR, alternatively, during a particular context initialization let contextConfig = ContextConfig() contextConfig.eventLogger = CustomEventLogger() ``` -The event data depends on the type of event. -Currently, the SDK logs the following events: +**Event Types** -| event | when | data | -|:----------:|------------------------------------------------------------|--------------------------------------------------------| -| `error` | `Context` receives an error | `Error` object | -| `ready` | `Context` turns ready | `ContextData` used to initialize the context | -| `refresh` | `Context.refresh()` method succeeds | `ContextData` used to refresh the context | -| `publish` | `Context.publish()` method succeeds | `PublishEvent` sent to the A/B Smartly event collector | -| `exposure` | `Context.getTreatment()` method succeeds on first exposure | `Exposure` enqueued for publishing | -| `goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing | -| `close` | `Context.close()` method succeeds the first time | `nil` | +The data parameter depends on the type of event. Currently, the SDK logs the following events: -#### Peek at treatment variants -Although generally not recommended, it is sometimes necessary to peek at a treatment or variable without triggering an exposure. -The A/B Smartly SDK provides a `peekTreatment()` method for that. +| Event | When | Data | +| ---------- | -------------------------------------------------- | ----------------------------------------- | +| `error` | Context receives an error | `Error` object | +| `ready` | Context turns ready | `ContextData` used to initialize | +| `refresh` | `refresh()` method succeeds | `ContextData` used to refresh | +| `publish` | `publish()` method succeeds | `PublishEvent` sent to collector | +| `exposure` | `getTreatment()` succeeds on first exposure | `Exposure` enqueued for publishing | +| `goal` | `track()` method succeeds | `GoalAchievement` enqueued for publishing | +| `close` | `close()` method succeeds the first time | `nil` | + +## Platform-Specific Examples + +### Using with SwiftUI (iOS 13+) ```swift -let treatment = context.peekTreatment(experimentName: "exp_test_experiment") +// ABSmartlyService.swift +import Foundation +import ABSmartly -if treatment == 0 { - // user is in control group (variant 0) -} else { - // user is in treatment group +class ABSmartlyService: ObservableObject { + static let shared = ABSmartlyService() + + private let sdk: ABsmartlySDK + + private init() { + sdk = try! ABsmartlySDK( + endpoint: "https://your-company.absmartly.io/v1", + apiKey: ProcessInfo.processInfo.environment["ABSMARTLY_API_KEY"] ?? "", + application: "ios-app", + environment: "production" + ) + } + + func createContext(deviceId: String) async throws -> Context { + let contextConfig = ContextConfig() + contextConfig.setUnit(unitType: "device_id", uid: deviceId) + + let context = sdk.createContext(config: contextConfig) + try await context.waitUntilReady() + + return context + } +} + +// ContentView.swift +import SwiftUI +import ABSmartly + +struct ContentView: View { + @StateObject private var absmartly = ABSmartlyService.shared + @State private var context: Context? + @State private var buttonColor: String = "blue" + + var body: some View { + VStack { + Button("Click Me") { + context?.track("button_clicked") + } + .foregroundColor(Color(buttonColor)) + .padding() + } + .task { + do { + let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + context = try await absmartly.createContext(deviceId: deviceId) + + let treatment = context?.getTreatment("button_test") + buttonColor = context?.getVariableValue("button.color", defaultValue: "blue") ?? "blue" + } catch { + print("Failed to initialize ABSmartly: \(error)") + } + } + .onDisappear { + context?.close() + } + } } ``` -##### Peeking at variables +### Using with UIKit (iOS 10+) + ```swift -let color = context.peekVariableValue("colorGComponent", defaultValue: 255) +// ExperimentViewController.swift +import UIKit +import ABSmartly +import PromiseKit + +class ExperimentViewController: UIViewController { + private var sdk: ABsmartlySDK! + private var context: Context? + + override func viewDidLoad() { + super.viewDidLoad() + + do { + sdk = try ABsmartlySDK( + endpoint: "https://your-company.absmartly.io/v1", + apiKey: "YOUR-API-KEY", + application: "ios-app", + environment: "production" + ) + } catch { + print("Failed to initialize SDK: \(error)") + return + } + + let contextConfig = ContextConfig() + contextConfig.setUnit(unitType: "device_id", uid: UIDevice.current.identifierForVendor?.uuidString ?? "") + + context = sdk.createContext(config: contextConfig) + context?.waitUntilReady().done { [weak self] ctx in + self?.setupExperiment(context: ctx) + }.catch { error in + print("Context failed: \(error)") + } + } + + private func setupExperiment(context: Context) { + let treatment = context.getTreatment("button_experiment") + let buttonTitle = context.getVariableValue("button.title", defaultValue: "Click Me") + + if treatment == 1 { + let backgroundColor = context.getVariableValue("button.background", defaultValue: "#007AFF") + } + } + + deinit { + context?.close() + } +} ``` -#### Overriding treatment variants -During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `override()` and/or `overrides()` methods. -The `setOverride()` and `setOverrides()` methods can be called before the context is ready. +### Using with macOS AppKit + ```swift -context.setOverride(experimentName: "exp_test_experiment", variant: 1) // force variant 1 of treatment -context.setOverrides(["exp_test_experiment": 1, "exp_another_experiment": 0]) +// AppDelegate.swift +import Cocoa +import ABSmartly +import PromiseKit + +@NSApplicationMain +class AppDelegate: NSObject, NSApplicationDelegate { + private var sdk: ABsmartlySDK! + private var context: Context? + + func applicationDidFinishLaunching(_ notification: Notification) { + do { + sdk = try ABsmartlySDK( + endpoint: "https://your-company.absmartly.io/v1", + apiKey: "YOUR-API-KEY", + application: "macos-app", + environment: "production" + ) + } catch { + print("Failed to initialize SDK: \(error)") + return + } + + let contextConfig = ContextConfig() + let machineId = getMachineIdentifier() + contextConfig.setUnit(unitType: "machine_id", uid: machineId) + + context = sdk.createContext(config: contextConfig) + context?.waitUntilReady().done { [weak self] ctx in + self?.runExperiment(context: ctx) + }.catch { error in + print("Context failed: \(error)") + } + } + + private func getMachineIdentifier() -> String { + let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + defer { IOObjectRelease(platformExpert) } + + guard let serialNumber = IORegistryEntryCreateCFProperty( + platformExpert, + kIOPlatformSerialNumberKey as CFString, + kCFAllocatorDefault, + 0 + ).takeRetainedValue() as? String else { + return UUID().uuidString + } + + return serialNumber + } + + private func runExperiment(context: Context) { + let featureEnabled = context.getTreatment("new_feature") == 1 + + if featureEnabled { + let featureConfig = context.getVariableValue("feature.config", defaultValue: [:]) + } + + context.track("app_launched") + } + + func applicationWillTerminate(_ notification: Notification) { + context?.close() + } +} +``` + +## Advanced Request Configuration + +### PromiseKit Cancellation (iOS 10+) + +The Swift SDK uses PromiseKit for async operations. You can cancel promises: + +```swift +import ABSmartly +import PromiseKit + +class ExperimentLoader { + private var sdk: ABsmartlySDK! + private var contextPromise: Promise? + + func loadExperiment(deviceId: String) { + let contextConfig = ContextConfig() + contextConfig.setUnit(unitType: "device_id", uid: deviceId) + + let context = sdk.createContext(config: contextConfig) + contextPromise = context.waitUntilReady() + + contextPromise?.done { ctx in + print("Context ready!") + }.catch { error in + if error is CancellableError { + print("Context loading cancelled") + } else { + print("Context failed: \(error.localizedDescription)") + } + } + } + + func cancelLoad() { + contextPromise?.cancel() + print("Cancelling context load...") + } +} +``` + +### iOS 13+ async/await with Task Cancellation + +```swift +import ABSmartly + +class ExperimentManager { + private var sdk: ABsmartlySDK! + private var contextTask: Task? + + func loadExperiment(deviceId: String) { + contextTask = Task { + let contextConfig = ContextConfig() + contextConfig.setUnit(unitType: "device_id", uid: deviceId) + + let context = sdk.createContext(config: contextConfig) + try await context.waitUntilReady() + + return context + } + + Task { + do { + let context = try await contextTask!.value + print("Context ready!") + await handleExperiment(context: context) + } catch is CancellationError { + print("Context loading cancelled") + } catch { + print("Context failed: \(error)") + } + } + } + + func cancelLoad() { + contextTask?.cancel() + print("Cancelling context load...") + } + + private func handleExperiment(context: Context) async { + let treatment = context.getTreatment("experiment_name") + } +} +``` + +### Timeout Override + +Override the default timeout: + +```swift +let sdk = try ABsmartlySDK( + endpoint: "https://your-company.absmartly.io/v1", + apiKey: "YOUR-API-KEY", + application: "ios-app", + environment: "production", + timeout: 10.0 // 10 seconds instead of default 3 seconds +) +``` + +For full control over the HTTP client configuration: + +```swift +let httpClientConfig = DefaultHTTPClientConfig() +httpClientConfig.connectionResourceTimeout = 10.0 +httpClientConfig.connectionRequestTimeout = 10.0 + +let httpClient = DefaultHTTPClient(config: httpClientConfig) +let clientConfig = ClientConfig( + apiKey: "YOUR-API-KEY", + application: "ios-app", + endpoint: "https://your-company.absmartly.io/v1", + environment: "production" +) + +let client = try DefaultClient(config: clientConfig, httpClient: httpClient) +let sdkConfig = ABsmartlyConfig(client: client) +let sdk = try ABsmartlySDK(config: sdkConfig) ``` ## About A/B Smartly + **A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. ### Have a look at our growing list of clients and SDKs: -- [Java SDK](https://www.github.com/absmartly/java-sdk) - [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) +- [Java SDK](https://www.github.com/absmartly/java-sdk) - [PHP SDK](https://www.github.com/absmartly/php-sdk) -- [Swift SDK](https://www.github.com/absmartly/swift-sdk) +- [Swift SDK](https://www.github.com/absmartly/swift-sdk) (this package) - [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) +- [React SDK](https://www.github.com/absmartly/react-sdk) +- [Python3 SDK](https://www.github.com/absmartly/python3-sdk) +- [Go SDK](https://www.github.com/absmartly/go-sdk) +- [Ruby SDK](https://www.github.com/absmartly/ruby-sdk) +- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) +- [Dart SDK](https://www.github.com/absmartly/dart-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) diff --git a/Sources/ABSmartly/ABSmartlyConfig.swift b/Sources/ABSmartly/ABSmartlyConfig.swift index 03902a0..dea4201 100644 --- a/Sources/ABSmartly/ABSmartlyConfig.swift +++ b/Sources/ABSmartly/ABSmartlyConfig.swift @@ -1,32 +1,45 @@ import Foundation -public class ABSmartlyConfig { - var scheduler: Scheduler? - var contextDataProvider: ContextDataProvider? - var contextEventHandler: ContextEventHandler? - var contextEventLogger: ContextEventLogger? - var variableParser: VariableParser? - var client: Client? +public class ABsmartlyConfig { + public var scheduler: Scheduler? + public var contextDataProvider: ContextDataProvider? + public var contextPublisher: ContextPublisher? + public var contextEventLogger: ContextEventLogger? + public var variableParser: VariableParser? + public var client: Client? + + /// Deprecated: Use contextPublisher instead. + @available(*, deprecated, renamed: "contextPublisher") + public var contextEventHandler: ContextPublisher? { + get { contextPublisher } + set { contextPublisher = newValue } + } public init() { } public convenience init(client: Client) { self.init( - contextDataProvider: nil, contextEventHandler: nil, contextEventLogger: nil, variableParser: nil, + contextDataProvider: nil, contextPublisher: nil, contextEventLogger: nil, variableParser: nil, scheduler: nil, client: client) } public init( - contextDataProvider: ContextDataProvider?, contextEventHandler: ContextEventHandler?, + contextDataProvider: ContextDataProvider?, contextPublisher: ContextPublisher?, contextEventLogger: ContextEventLogger?, variableParser: VariableParser?, scheduler: Scheduler?, client: Client? ) { self.scheduler = scheduler self.contextDataProvider = contextDataProvider - self.contextEventHandler = contextEventHandler + self.contextPublisher = contextPublisher self.contextEventLogger = contextEventLogger self.variableParser = variableParser self.client = client } } + +@available(*, deprecated, message: "Use ABsmartlyConfig instead") +public typealias AbsmartlyConfig = ABsmartlyConfig + +@available(*, deprecated, message: "Use ABsmartlyConfig instead") +public typealias ABSmartlyConfig = ABsmartlyConfig diff --git a/Sources/ABSmartly/ABSmartlySDK.swift b/Sources/ABSmartly/ABSmartlySDK.swift index 1fc2e64..884a862 100644 --- a/Sources/ABSmartly/ABSmartlySDK.swift +++ b/Sources/ABSmartly/ABSmartlySDK.swift @@ -1,31 +1,94 @@ import Foundation import PromiseKit -public final class ABSmartlySDK { +public final class ABsmartlySDK { private var client: Client? private let contextDataProvider: ContextDataProvider - private let contextEventHandler: ContextEventHandler + private let contextEventHandler: ContextPublisher private let contextEventLogger: ContextEventLogger? private let variableParser: VariableParser private let scheduler: Scheduler - public init(config: ABSmartlyConfig) throws { + public init(config: ABsmartlyConfig) throws { contextEventLogger = config.contextEventLogger variableParser = config.variableParser ?? DefaultVariableParser() scheduler = config.scheduler ?? DefaultScheduler() client = config.client - if config.contextDataProvider == nil || config.contextEventHandler == nil { - if client == nil { + if config.contextDataProvider == nil || config.contextPublisher == nil { + guard let client = client else { throw ABSmartlyError("Missing Client instance") } - contextDataProvider = config.contextDataProvider ?? DefaultContextDataProvider(client: client!) - contextEventHandler = config.contextEventHandler ?? DefaultContextEventHandler(client: client!) + contextDataProvider = config.contextDataProvider ?? DefaultContextDataProvider(client: client) + contextEventHandler = config.contextPublisher ?? DefaultContextPublisher(client: client) } else { - contextDataProvider = config.contextDataProvider! - contextEventHandler = config.contextEventHandler! + guard let provider = config.contextDataProvider, let handler = config.contextPublisher else { + throw ABSmartlyError("Missing contextDataProvider or contextPublisher") + } + contextDataProvider = provider + contextEventHandler = handler + } + } + + public convenience init( + endpoint: String, + apiKey: String, + application: String, + environment: String, + applicationVersion: String = "0", + timeout: TimeInterval = 3.0, + retries: UInt = 5, + contextEventLogger: ContextEventLogger? = nil, + contextDataProvider: ContextDataProvider? = nil, + contextEventHandler: ContextEventHandler? = nil, + variableParser: VariableParser? = nil, + scheduler: Scheduler? = nil + ) throws { + if endpoint.isEmpty { + throw ABSmartlyError("Missing Endpoint configuration") + } + + if apiKey.isEmpty { + throw ABSmartlyError("Missing APIKey configuration") + } + + if application.isEmpty { + throw ABSmartlyError("Missing Application configuration") + } + + if environment.isEmpty { + throw ABSmartlyError("Missing Environment configuration") } + + let clientConfig = ClientConfig( + apiKey: apiKey, + application: application, + endpoint: endpoint, + environment: environment, + applicationVersion: applicationVersion + ) + + let httpClientConfig = DefaultHTTPClientConfig() + httpClientConfig.connectionResourceTimeout = timeout + httpClientConfig.connectionRequestTimeout = timeout + httpClientConfig.retries = retries + + let client = try DefaultClient( + config: clientConfig, + httpClient: DefaultHTTPClient(config: httpClientConfig) + ) + + let sdkConfig = ABsmartlyConfig( + contextDataProvider: contextDataProvider, + contextPublisher: contextEventHandler, + contextEventLogger: contextEventLogger, + variableParser: variableParser, + scheduler: scheduler, + client: client + ) + + try self.init(config: sdkConfig) } public func createContextWithData(config: ContextConfig, contextData: ContextData) -> Context { @@ -49,21 +112,16 @@ public final class ABSmartlySDK { } public func close() -> Promise { - if client == nil { + guard let clientToClose = client else { return Promise.value(()) } - - return Promise { seal in - if client != nil { - client!.close().done { - seal.fulfill(()) - }.catch { error in - seal.reject(error) - } - client = nil - } else { - seal.fulfill(()) - } - } + client = nil + return clientToClose.close() } } + +@available(*, deprecated, message: "Use ABsmartlySDK instead") +public typealias AbsmartlySDK = ABsmartlySDK + +@available(*, deprecated, message: "Use ABsmartlySDK instead") +public typealias ABSmartlySDK = ABsmartlySDK diff --git a/Sources/ABSmartly/Application.swift b/Sources/ABSmartly/Application.swift index 6ebc4a4..873493b 100644 --- a/Sources/ABSmartly/Application.swift +++ b/Sources/ABSmartly/Application.swift @@ -1,13 +1,9 @@ import Foundation -public class Application: Codable, Equatable { +public struct Application: Codable, Equatable { public let name: String? init(_ name: String) { self.name = name } - - public static func == (lhs: Application, rhs: Application) -> Bool { - return lhs.name == rhs.name - } } diff --git a/Sources/ABSmartly/AudienceMatcher.swift b/Sources/ABSmartly/AudienceMatcher.swift index 36e0e18..3b02287 100644 --- a/Sources/ABSmartly/AudienceMatcher.swift +++ b/Sources/ABSmartly/AudienceMatcher.swift @@ -7,15 +7,19 @@ public class AudienceMatcher { let json = JSON(parseJSON: audience) let filter = json["filter"] - if filter.exists() { - switch filter.type { - case .dictionary, .array: - return jsonExpr.evaluateBooleanExpr(filter, vars: attributes) - default: - break - } + guard filter.exists() else { + let truncated = audience.count > 100 ? "\(audience.prefix(100))..." : audience + Logger.error("Audience JSON missing 'filter' field. Audience: '\(truncated)'") + return nil } - return nil + switch filter.type { + case .dictionary, .array: + return jsonExpr.evaluateBooleanExpr(filter, vars: attributes) + default: + let truncated = audience.count > 100 ? "\(audience.prefix(100))..." : audience + Logger.error("Audience filter has invalid type: \(filter.type), expected dictionary or array. Audience: '\(truncated)'") + return nil + } } } diff --git a/Sources/ABSmartly/ClientConfig.swift b/Sources/ABSmartly/ClientConfig.swift index cd1268d..1818612 100644 --- a/Sources/ABSmartly/ClientConfig.swift +++ b/Sources/ABSmartly/ClientConfig.swift @@ -1,31 +1,50 @@ import Foundation public class ClientConfig { - public var apiKey: String = "" - public var application: String = "" - public var endpoint: String = "" - public var environment: String = "" + public private(set) var apiKey: String = "" + public private(set) var application: String = "" + public private(set) var applicationVersion: String = "0" + public private(set) var endpoint: String = "" + public private(set) var environment: String = "" public init() { } public init( - apiKey: String, application: String, endpoint: String, environment: String + apiKey: String, + application: String, + endpoint: String, + environment: String, + applicationVersion: String = "0" ) { self.apiKey = apiKey self.application = application + self.applicationVersion = applicationVersion self.endpoint = endpoint self.environment = environment } public convenience init(from data: Data) { - let dict = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: String] - self.init(from: dict ?? [:]) + do { + if let dict = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: String] { + self.init(from: dict) + } else { + Logger.error("Failed to parse ClientConfig plist: result is not a [String: String] dictionary") + self.init(from: [:]) + } + } catch { + Logger.error("Failed to parse ClientConfig plist: \(error.localizedDescription)") + self.init(from: [:]) + } } public convenience init(from dict: [String: String]) { self.init( - apiKey: dict["apikey"] ?? "", application: dict["application"] ?? "", endpoint: dict["endpoint"] ?? "", - environment: dict["environment"] ?? "") + apiKey: dict["apikey"] ?? "", + application: dict["application"] ?? "", + endpoint: dict["endpoint"] ?? "", + environment: dict["environment"] ?? "", + applicationVersion: dict["applicationVersion"] ?? "0" + ) } } diff --git a/Sources/ABSmartly/Context.swift b/Sources/ABSmartly/Context.swift index 44f9cb0..a7eb8ae 100644 --- a/Sources/ABSmartly/Context.swift +++ b/Sources/ABSmartly/Context.swift @@ -1,6 +1,5 @@ import Atomics import Foundation -import MapKit import PromiseKit public final class Context { @@ -8,6 +7,11 @@ public final class Context { private let scheduler: Scheduler private let handler: ContextEventHandler private let provider: ContextDataProvider + + deinit { + clearRefreshTimer() + clearTimeout() + } private let logger: ContextEventLogger? private let parser: VariableParser private let matcher: AudienceMatcher @@ -15,10 +19,12 @@ public final class Context { private var pendingCount = ManagedAtomic(0) - private var failed: Bool = false + private var ready = ManagedAtomic(false) + private var failed = ManagedAtomic(false) private var closed = ManagedAtomic(false) private var closing = ManagedAtomic(false) private var refreshing = ManagedAtomic(false) + private var readyPromise: Promise? private var refreshPromise: Promise? private var closePromise: Promise? @@ -32,6 +38,7 @@ public final class Context { private var indexVariables: [String: [ExperimentVariables]] = [:] private var customFieldValues: [String: [String: ContextCustomFieldValue]] = [:] private var data: ContextData? = nil + private var failedError: Error? = nil private var hashedUnits: [String: [UInt8]] = [:] private var assigners: [String: VariantAssigner] = [:] @@ -40,15 +47,20 @@ public final class Context { private let contextLock = NSRecursiveLock() private var units: [String: String] = [:] private var attributes: [Attribute] = [] + private let maxAttributes = 500 private var overrides: [String: Int] = [:] private var cassignments: [String: Int] = [:] private let eventLock = NSLock() + private let promiseLock = NSLock() private var exposures: [Exposure] = [] private var achievements: [GoalAchievement] = [] + private let maxExposures = 500 + private let maxAchievements = 500 private var publishDelay: TimeInterval = 0 private var refreshInterval: TimeInterval = 0 + private var attrsSeq: Int = 0 init( config: ContextConfig, clock: Clock, scheduler: Scheduler, handler: ContextEventHandler, @@ -94,50 +106,90 @@ public final class Context { logError(error: error) } } else { - readyPromise = Promise { seal in - promise.done { [self] data in - setData(data) + readyPromise = Promise { [weak self] seal in + guard let self = self else { seal.fulfill(()) - readyPromise = nil + return + } - logEvent(event: .ready(data: data)) + promise.done(on: DispatchQueue.global()) { [weak self] data in + guard let self = self else { return } + self.setData(data) + self.promiseLock.lock() + self.readyPromise = nil + self.promiseLock.unlock() - if pendingCount.load(ordering: .relaxed) > 0 { - setTimeout() + self.logEvent(event: .ready(data: data)) + if self.pendingCount.load(ordering: .acquiring) > 0 { + self.setTimeout() + } + seal.fulfill(()) + }.catch(on: DispatchQueue.global()) { [weak self] error in + guard let self = self else { return } + self.setDataFailed(error) + self.promiseLock.lock() + self.readyPromise = nil + self.promiseLock.unlock() + Logger.error("Context initialization failed: \(error.localizedDescription)") + + self.logError(error: error) + seal.reject(error) } - }.catch { [self] error in - setDataFailed(error) - readyPromise = nil - seal.fulfill(()) // throw no user-visible errors - - logError(error: error) } } } - } public func isReady() -> Bool { - return failed || data != nil + return ready.load(ordering: .acquiring) || failed.load(ordering: .acquiring) } public func isFailed() -> Bool { - return failed + return failed.load(ordering: .acquiring) + } + + public func readyError() -> Error? { + dataLock.lock() + defer { dataLock.unlock() } + return failedError } public func isClosing() -> Bool { - return !closed.load(ordering: .relaxed) && closing.load(ordering: .relaxed) + return !closed.load(ordering: .acquiring) && closing.load(ordering: .acquiring) } public func isClosed() -> Bool { - return closed.load(ordering: .relaxed) + return closed.load(ordering: .acquiring) + } + + public func isFinalizing() -> Bool { + return isClosing() + } + + public func isFinalized() -> Bool { + return isClosed() + } + + public func finalize() -> Promise { + return close() } public func waitUntilReady() -> Promise { - return Promise { seal in - if isReady() || readyPromise == nil { + return Promise { [weak self] seal in + guard let self = self else { + seal.reject(ABSmartlyError("Context was deallocated")) + return + } + self.promiseLock.lock() + let currentReadyPromise = self.readyPromise + self.promiseLock.unlock() + if self.isReady() || currentReadyPromise == nil { seal.fulfill(self) - } else if let ready = readyPromise { - _ = ready.done { + } else if let ready = currentReadyPromise { + _ = ready.done(on: DispatchQueue.global()) { [weak self] in + guard let self = self else { return } + seal.fulfill(self) + }.catch(on: DispatchQueue.global()) { [weak self] _ in + guard let self = self else { return } seal.fulfill(self) } } @@ -145,8 +197,6 @@ public final class Context { } public func getExperiments() -> [String] { - checkReady(true) - dataLock.lock() defer { dataLock.unlock() } return data?.experiments.map { $0.name } ?? [] @@ -158,55 +208,45 @@ public final class Context { dataLock.lock() defer { dataLock.unlock() } - for experiment in data!.experiments { - let customFieldValues = experiment.customFieldValues - if (customFieldValues != nil) { - for customFieldValue in customFieldValues! { - keys.insert(customFieldValue.name!); - } + guard let data = data else { return keys } + + for experiment in data.experiments { + guard let customFieldValues = experiment.customFieldValues else { continue } + for customFieldValue in customFieldValues { + guard let name = customFieldValue.name else { continue } + keys.insert(name) } } return keys } - public func getCustomFieldValue(experimentName: String, key: String) -> Any? { - var keys: [String] = [] - + public func getCustomFieldKeys(experimentName: String) -> [String] { dataLock.lock() defer { dataLock.unlock() } - var experimentCustomFieldValues = customFieldValues[experimentName] - - if (experimentCustomFieldValues != nil) { - var field = experimentCustomFieldValues?[key] - if (field != nil) { - return field?.value; - } + guard let experimentCustomFields = customFieldValues[experimentName] else { + return [] } - return nil; - } - public func getCustomFieldValueType(experimentName: String, key: String) -> String? { - var keys: [String] = [] + return Array(experimentCustomFields.keys) + } + public func getCustomFieldValue(experimentName: String, key: String) -> Any? { dataLock.lock() defer { dataLock.unlock() } - var experimentCustomFieldValues = customFieldValues[experimentName] + return customFieldValues[experimentName]?[key]?.value + } - if (experimentCustomFieldValues != nil) { - var field = experimentCustomFieldValues?[key] - if (field != nil) { - return field?.type; - } - } - return nil; + public func getCustomFieldValueType(experimentName: String, key: String) -> String? { + dataLock.lock() + defer { dataLock.unlock() } + + return customFieldValues[experimentName]?[key]?.type } public func getContextData() -> ContextData? { - checkReady(true) - dataLock.lock() defer { dataLock.unlock() } return data @@ -231,8 +271,6 @@ public final class Context { } public func setOverride(experimentName: String, variant: Int) { - checkNotClosed() - _ = putLocked(lock: contextLock, dict: &overrides, key: experimentName, value: variant) } @@ -241,11 +279,13 @@ public final class Context { } public func setOverrides(_ overrides: [String: Int]) { - overrides.forEach { setOverride(experimentName: $0.key, variant: $0.value) } + for (key, value) in overrides { + setOverride(experimentName: key, variant: value) + } } public func setCustomAssignment(experimentName: String, variant: Int) { - checkNotClosed() + guard checkNotClosed() else { return } _ = putLocked(lock: contextLock, dict: &cassignments, key: experimentName, value: variant) } @@ -255,29 +295,41 @@ public final class Context { } public func setCustomAssignments(_ assignments: [String: Int]) { - assignments.forEach { setCustomAssignment(experimentName: $0.key, variant: $0.value) } + for (key, value) in assignments { + setCustomAssignment(experimentName: key, variant: value) + } } public func getUnit(unitType: String) -> String? { return getLocked(lock: contextLock, dict: units, key: unitType) } + private static let maxUnitUIDLength = 256 + public func setUnit(unitType: String, uid: String) { - checkNotClosed() + guard !isClosed() && !isClosing() else { + Logger.error(isClosed() ? "ABsmartly Context is finalized." : "ABsmartly Context is closing.") + return + } let trimmed = uid.trimmingCharacters(in: .whitespacesAndNewlines) - precondition(!trimmed.isEmpty, "Unit '\(unitType)' UID must not be blank.") + guard !trimmed.isEmpty else { + Logger.error("Unit '\(unitType)' UID must not be blank.") + return + } + + guard trimmed.count <= Self.maxUnitUIDLength else { + Logger.error("Unit '\(unitType)' UID exceeds maximum length of \(Self.maxUnitUIDLength) characters") + return + } contextLock.lock() defer { contextLock.unlock() } - precondition( - { - if let previous = units[unitType], previous != uid { - return false - } - return true - }(), "Unit '\(unitType)' already set.") + if let previous = units[unitType], previous != trimmed { + Logger.error("Unit '\(unitType)' UID already set.") + return + } units[unitType] = trimmed } @@ -290,7 +342,9 @@ public final class Context { } public func setUnits(_ units: [String: String]) { - units.forEach { setUnit(unitType: $0, uid: $1) } + for (unitType, uid) in units { + setUnit(unitType: unitType, uid: uid) + } } public func getAttribute(name: String) -> JSON? { @@ -307,12 +361,17 @@ public final class Context { } public func setAttribute(name: String, value: JSON) { - checkNotClosed() + guard checkNotClosed() else { return } contextLock.lock() defer { contextLock.unlock() } + if attributes.count >= maxAttributes { + attributes.removeFirst(maxAttributes / 4) + } + attributes.append(Attribute(name, value: value, setAt: clock.millis())) + attrsSeq += 1 } public func getAttributes() -> [String: JSON] { @@ -328,14 +387,14 @@ public final class Context { } public func setAttributes(_ attributes: [String: JSON]) { - attributes.forEach { setAttribute(name: $0, value: $1) } + for (name, value) in attributes { + setAttribute(name: name, value: value) + } } public func getTreatment(_ experimentName: String) -> Int { - checkReady(true) - let assignment = getAssignment(experimentName) - if !assignment.exposed.load(ordering: .relaxed) { + if !assignment.exposed.load(ordering: .acquiring) { queueExposure(assignment) } @@ -356,8 +415,14 @@ public final class Context { eventLock.lock() defer { eventLock.unlock() } + if exposures.count >= maxExposures { + let removeCount = maxExposures / 4 + exposures.removeFirst(removeCount) + pendingCount.wrappingDecrement(by: UInt(removeCount), ordering: .releasing) + } + exposures.append(exposure) - pendingCount.wrappingIncrement(by: 1, ordering: .relaxed) + pendingCount.wrappingIncrement(by: 1, ordering: .releasing) } logEvent(event: .exposure(exposure: exposure)) @@ -366,14 +431,10 @@ public final class Context { } public func peekTreatment(_ experimentName: String) -> Int { - checkReady(true) - return getAssignment(experimentName).variant } public func getVariableKeys() -> [String: [String]] { - checkReady(true) - dataLock.lock() defer { dataLock.unlock() } @@ -381,14 +442,12 @@ public final class Context { } public func getVariableValue(_ key: String, defaultValue: JSON? = nil) -> JSON? { - checkReady(true) - - if let assignment = getVariableAssignment(key) { - if !assignment.exposed.load(ordering: .relaxed) { + if let assignment = getVariableAssignment(key), let variables = assignment.variables { + if !assignment.exposed.load(ordering: .acquiring) { queueExposure(assignment) } - if let object = assignment.variables[key] { + if let object = variables[key] { return object } } @@ -397,10 +456,8 @@ public final class Context { } public func peekVariableValue(_ key: String, defaultValue: JSON? = nil) -> JSON? { - checkReady(true) - - if let assignment = getVariableAssignment(key) { - if let object = assignment.variables[key] { + if let assignment = getVariableAssignment(key), let variables = assignment.variables { + if let object = variables[key] { return object } } @@ -409,7 +466,7 @@ public final class Context { } public func track(_ goalName: String, properties: [String: JSON]? = nil) { - checkNotClosed() + guard checkNotClosed() else { return } let achievement: GoalAchievement = GoalAchievement( goalName, achievedAt: clock.millis(), properties: properties) @@ -418,8 +475,14 @@ public final class Context { eventLock.lock() defer { eventLock.unlock() } + if achievements.count >= maxAchievements { + let removeCount = maxAchievements / 4 + achievements.removeFirst(removeCount) + pendingCount.wrappingDecrement(by: UInt(removeCount), ordering: .releasing) + } + achievements.append(achievement) - pendingCount.wrappingIncrement(by: 1, ordering: .relaxed) + pendingCount.wrappingIncrement(by: 1, ordering: .releasing) } logEvent(event: .goal(goal: achievement)) @@ -428,74 +491,106 @@ public final class Context { } public func getPendingCount() -> UInt { - return pendingCount.load(ordering: .relaxed) + return pendingCount.load(ordering: .acquiring) } public func publish() -> Promise { - checkNotClosed() + guard checkNotClosed() else { return Promise.value(()) } return flush() } public func refresh() -> Promise { - checkNotClosed() + guard checkNotClosed() else { return Promise.value(()) } if !refreshing.compareExchange(expected: false, desired: true, ordering: .acquiringAndReleasing).0 { - return refreshPromise! + promiseLock.lock() + let existingPromise = refreshPromise + promiseLock.unlock() + if let existingPromise = existingPromise { + return existingPromise + } + return Promise.value(()) } - refreshPromise = Promise { seal in - provider.getContextData().done { [self] data in - setData(data) - refreshing.store(false, ordering: .relaxed) + let promise = Promise { [weak self] seal in + guard let self = self else { seal.fulfill(()) + return + } - logEvent(event: .refresh(data: data)) - }.catch { [self] error in - refreshing.store(false, ordering: .relaxed) - seal.reject(error) + self.provider.getContextData().done(on: DispatchQueue.global()) { [weak self] data in + guard let self = self else { return } + self.setData(data) + self.refreshing.store(false, ordering: .releasing) + self.logEvent(event: .refresh(data: data)) + seal.fulfill(()) + }.catch(on: DispatchQueue.global()) { [weak self] error in + guard let self = self else { return } + self.refreshing.store(false, ordering: .releasing) - logError(error: error) + Logger.error("Context refresh failed: \(error.localizedDescription)") + self.logError(error: error) + + seal.reject(error) } } - return refreshPromise! + promiseLock.lock() + refreshPromise = promise + promiseLock.unlock() + return promise } public func close() -> Promise { - if !closed.load(ordering: .relaxed) { - if !closing.compareExchange(expected: false, desired: true, ordering: .relaxed).0 { - return closePromise! + if !closed.load(ordering: .acquiring) { + if !closing.compareExchange(expected: false, desired: true, ordering: .acquiringAndReleasing).0 { + promiseLock.lock() + let existingPromise = closePromise + promiseLock.unlock() + if let existingPromise = existingPromise { + return existingPromise + } + return Promise.value(()) } - closePromise = Promise { seal in - clearRefreshTimer() + let newClosePromise = Promise { [weak self] seal in + guard let self = self else { + seal.fulfill(()) + return + } + + self.clearRefreshTimer() - if pendingCount.load(ordering: .relaxed) > 0 { - flush().done { [self] in - closed.store(true, ordering: .relaxed) - closing.store(false, ordering: .relaxed) + if self.pendingCount.load(ordering: .acquiring) > 0 { + self.flush().done(on: DispatchQueue.global()) { [weak self] in + guard let self = self else { return } + self.closed.store(true, ordering: .releasing) + self.closing.store(false, ordering: .releasing) + self.logEvent(event: .close) seal.fulfill(()) - - logEvent(event: .close) - }.catch({ [self] error in - closed.store(true, ordering: .relaxed) - closing.store(true, ordering: .relaxed) + }.catch(on: DispatchQueue.global()) { [weak self] error in + guard let self = self else { return } + self.closed.store(true, ordering: .releasing) + self.closing.store(false, ordering: .releasing) seal.reject(error) - - // event logger gets this error during publish - }) + } } else { - closed.store(true, ordering: .relaxed) - closing.store(false, ordering: .relaxed) + self.closed.store(true, ordering: .releasing) + self.closing.store(false, ordering: .releasing) + self.logEvent(event: .close) seal.fulfill(()) - - logEvent(event: .close) } } + promiseLock.lock() + closePromise = newClosePromise + promiseLock.unlock() } - if let closePromise = closePromise { + promiseLock.lock() + let currentClosePromise = closePromise + promiseLock.unlock() + if let closePromise = currentClosePromise { return closePromise } return Promise.value(()) @@ -505,16 +600,18 @@ public final class Context { clearTimeout() if !isFailed() { - var eventCount = pendingCount.load(ordering: .relaxed) + var eventCount = pendingCount.load(ordering: .acquiring) if eventCount > 0 { var localExposures: [Exposure] = [] var localAchievements: [GoalAchievement] = [] + var localUnits: [Unit] = [] + var localAttributes: [Attribute] = [] do { eventLock.lock() defer { eventLock.unlock() } - eventCount = pendingCount.load(ordering: .relaxed) + eventCount = pendingCount.load(ordering: .acquiring) if eventCount > 0 { if !exposures.isEmpty { localExposures = exposures @@ -526,32 +623,46 @@ public final class Context { achievements = [] } - pendingCount.store(0, ordering: .relaxed) + pendingCount.store(0, ordering: .releasing) } } if eventCount > 0 { + contextLock.lock() + defer { contextLock.unlock() } + localUnits = units.map { + let hashBytes = getUnitHash($0.key, $0.value) + if let hashString = String(bytes: hashBytes, encoding: .ascii) { + return Unit(type: $0.key, uid: hashString) + } else { + Logger.error("Failed to encode unit hash for type '\($0.key)' to ASCII. Using base64 fallback.") + return Unit(type: $0.key, uid: Data(hashBytes).base64EncodedString()) + } + } + localAttributes = attributes + let event = PublishEvent( true, - units.map { - Unit( - type: $0.key, uid: String(bytes: getUnitHash($0.key, $0.value), encoding: .ascii) ?? "") - }, + localUnits, clock.millis(), localExposures, localAchievements, - attributes) - - return Promise { [self] seal in - _ = handler.publish(event: event).done { [self] in - seal.fulfill(()) - - logEvent(event: .publish(event: event)) - }.catch { [self] error in - seal.reject(error) - - logError(error: error) - } + localAttributes) + + return handler.publish(event: event).done(on: DispatchQueue.global()) { [weak self] in + guard let self = self else { return } + self.logEvent(event: .publish(event: event)) + }.recover(on: DispatchQueue.global()) { [weak self] error -> Promise in + guard let self = self else { return Promise.value(()) } + self.eventLock.lock() + self.exposures.insert(contentsOf: localExposures, at: 0) + self.achievements.insert(contentsOf: localAchievements, at: 0) + self.pendingCount.wrappingIncrement(by: UInt(eventCount), ordering: .releasing) + self.eventLock.unlock() + + Logger.error("Publish failed: \(error.localizedDescription)") + self.logError(error: error) + throw error } } } @@ -561,22 +672,41 @@ public final class Context { exposures = [] achievements = [] - pendingCount.store(0, ordering: .relaxed) + pendingCount.store(0, ordering: .releasing) } return Promise.value(()) } - private func checkReady(_ expectNotClosed: Bool) { - precondition(isReady(), "ABSmartly Context is not yet ready.") + private func checkReady(_ expectNotClosed: Bool) -> Bool { + if !isReady() { + Logger.error("ABsmartly Context is not yet ready.") + return false + } if expectNotClosed { - checkNotClosed() + return checkNotClosed() + } + return true + } + + private func checkNotClosed() -> Bool { + if isClosed() { + Logger.error("ABsmartly Context is finalized.") + return false } + if isClosing() { + Logger.error("ABsmartly Context is finalizing.") + return false + } + return true } - private func checkNotClosed() { - precondition(!isClosed(), "ABSmartly Context is closed.") - precondition(!isClosing(), "ABSmartly Context is closing.") + private func buildAttributeMap() -> [String: JSON] { + var attrs: [String: JSON] = [:] + for attr in attributes { + attrs[attr.name] = attr.value + } + return attrs } private func experimentMatches(_ experiment: Experiment, _ assignment: Assignment) -> Bool { @@ -585,6 +715,24 @@ public final class Context { && experiment.trafficSplit == assignment.trafficSplit } + private func audienceMatches(_ experiment: Experiment, _ assignment: Assignment) -> Bool { + if let audience = experiment.audience, audience.count > 0 { + if attrsSeq > assignment.attrsSeq { + let attrs = buildAttributeMap() + + let result = matcher.evaluate(audience, attrs) + let newAudienceMismatch = result != nil ? !result! : false + + if newAudienceMismatch != assignment.audienceMismatch { + return false + } + + assignment.attrsSeq = attrsSeq + } + } + return true + } + private func getExperiment(_ experimentName: String) -> ExperimentVariables? { return getLocked(lock: dataLock, dict: index, key: experimentName) } @@ -606,13 +754,16 @@ public final class Context { // previously not-running experiment return assignment } - } else { + } else if let exp = experiment { let custom = cassignments[experimentName] - if custom == nil || custom! == assignment.variant { - if experimentMatches(experiment!.data, assignment) { + if let customVariant = custom { + if customVariant == assignment.variant && experimentMatches(exp.data, assignment) && audienceMatches(exp.data, assignment) { // assignment up-to-date return assignment } + } else if experimentMatches(exp.data, assignment) && audienceMatches(exp.data, assignment) { + // assignment up-to-date + return assignment } } } @@ -635,10 +786,7 @@ public final class Context { if let audience = experiment.data.audience { if audience.count > 0 { - var attrs: [String: JSON] = [:] - for attr in attributes { - attrs[attr.name] = attr.value - } + let attrs = buildAttributeMap() if let result = matcher.evaluate(audience, attrs) { assignment.audienceMismatch = !result @@ -685,10 +833,11 @@ public final class Context { assignment.iteration = experiment.data.iteration assignment.trafficSplit = experiment.data.trafficSplit assignment.fullOnVariant = experiment.data.fullOnVariant + assignment.attrsSeq = attrsSeq } } - if let experiment = experiment, assignment.variant < experiment.data.variants.count { + if let experiment = experiment, assignment.variant >= 0, assignment.variant < experiment.variables.count { assignment.variables = experiment.variables[assignment.variant] } @@ -721,7 +870,7 @@ public final class Context { if let unitHash = hashedUnits[unitType] { return unitHash } - let hashValue: [UInt8] = Hashing.hash(unitUID) + let hashValue: [UInt8] = Hashing.hashBytes(unitUID) hashedUnits[unitType] = hashValue return hashValue } @@ -742,49 +891,54 @@ public final class Context { private func setTimeout() { guard isReady() else { return } + guard publishDelay >= 0 else { return } - if timeout == nil { - timeoutLock.lock() - defer { timeoutLock.unlock() } + timeoutLock.lock() + defer { timeoutLock.unlock() } - if timeout == nil { + if timeout == nil { timeout = scheduler.schedule( after: publishDelay, - execute: { [self] in - _ = flush() + execute: { [weak self] in + guard let self = self else { return } + self.flush().catch { error in + Logger.error("Auto-flush failed: \(error.localizedDescription)") + self.logError(error: error) + } }) - } } } private func clearTimeout() { - if timeout != nil { - timeoutLock.lock() - defer { timeoutLock.unlock() } + timeoutLock.lock() + defer { timeoutLock.unlock() } - timeout?.cancel() - timeout = nil - } + timeout?.cancel() + timeout = nil } private func setRefreshTimer() { if refreshInterval > 0 && refreshTimer == nil { refreshTimer = scheduler.scheduleWithFixedDelay( after: refreshInterval, repeating: refreshInterval, - execute: { [self] in - _ = refresh().done {} + execute: { [weak self] in + guard let self = self else { return } + self.refresh() + .done { } + .catch { error in + Logger.error("Auto-refresh failed: \(error.localizedDescription)") + self.logError(error: error) + } }) } } private func clearRefreshTimer() { - if refreshTimer != nil { - refreshTimer!.cancel() - refreshTimer = nil - } + refreshTimer?.cancel() + refreshTimer = nil } - private func setData(_ data: ContextData) { + public func setData(_ data: ContextData) { var index: [String: ExperimentVariables] = [:] var indexVariables: [String: [ExperimentVariables]] = [:] var customFieldValues: [String: [String: ContextCustomFieldValue]] = [:] @@ -811,25 +965,43 @@ public final class Context { } } - if (experiment.customFieldValues != nil) { - for customFieldValue in experiment.customFieldValues! { - var value = ContextCustomFieldValue() - value.type = customFieldValue.type!; - - if (customFieldValue.value != nil) { - var customValue = customFieldValue.value; - if ((customFieldValue.type!.starts(with: "json"))) { - value.value = parser.parse(experimentName: experiment.name, config: customValue!) - } else if ((customFieldValue.type!.starts(with: "boolean"))) { - value.value = Bool(customValue!) - } else if ((customFieldValue.type!.starts(with: "number"))) { - value.value = Double(customValue!) + if let fieldValues = experiment.customFieldValues { + for customFieldValue in fieldValues { + guard let fieldType = customFieldValue.type, + let fieldName = customFieldValue.name else { continue } + + let value = ContextCustomFieldValue() + value.type = fieldType + + if let customValue = customFieldValue.value { + if fieldType.starts(with: "json") { + let data = Data(customValue.utf8) + do { + let jsonObject = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + let nativeValue = jsonObjectToNative(jsonObject) + value.value = nativeValue + } catch { + let truncated = customValue.count > 100 ? "\(customValue.prefix(100))..." : customValue + Logger.error("Failed to parse JSON custom field '\(fieldName)' for experiment '\(experiment.name)': \(error.localizedDescription). Original value: '\(truncated)'") + value.value = nil + } + } else if fieldType.starts(with: "boolean") { + let lowercased = customValue.lowercased() + value.value = lowercased == "true" || lowercased == "1" + } else if fieldType.starts(with: "number") { + if let intVal = Int(customValue) { + value.value = intVal + } else if let doubleVal = Double(customValue) { + value.value = doubleVal + } else { + value.value = nil + } } else { - value.value = customFieldValue.value; + value.value = customFieldValue.value } } - experimentCustomFieldValues[customFieldValue.name!] = value; + experimentCustomFieldValues[fieldName] = value } } @@ -837,17 +1009,28 @@ public final class Context { customFieldValues[experiment.name] = experimentCustomFieldValues } - - dataLock.lock() - defer { - dataLock.unlock() - } - self.data = data self.index = index self.indexVariables = indexVariables self.customFieldValues = customFieldValues + dataLock.unlock() + + contextLock.lock() + defer { contextLock.unlock() } + for (experimentName, assignment) in assignmentCache { + if assignment.overridden { + continue + } + if let experiment = index[experimentName] { + if !experimentMatches(experiment.data, assignment) { + assignment.exposed.store(false, ordering: .releasing) + } + } else { + assignment.exposed.store(false, ordering: .releasing) + } + } + ready.store(true, ordering: .releasing) setRefreshTimer() } @@ -859,19 +1042,41 @@ public final class Context { index = [:] indexVariables = [:] data = nil - failed = true + failedError = error + failed.store(true, ordering: .releasing) } - private func logEvent(event: ContextEventLoggerEvent) { - if let logger = logger { - logger.handleEvent(context: self, event: event) + private func jsonObjectToNative(_ jsonObject: Any) -> Any? { + if jsonObject is NSNull { + return nil + } else if let dict = jsonObject as? [String: Any] { + var result: [String: Any] = [:] + for (key, value) in dict { + if let nativeValue = jsonObjectToNative(value) { + result[key] = nativeValue + } + } + return result + } else if let array = jsonObject as? [Any] { + return array.compactMap { jsonObjectToNative($0) } + } else if let string = jsonObject as? String { + return string + } else if let number = jsonObject as? NSNumber { + let objCType = String(cString: number.objCType) + if objCType == "c" || objCType == "B" { + return number.boolValue + } + return number } + return jsonObject + } + + private func logEvent(event: ContextEventLoggerEvent) { + logger?.handleEvent(context: self, event: event) } private func logError(error: Error) { - if let logger = logger { - logger.handleEvent(context: self, event: ContextEventLoggerEvent.error(error: error)) - } + logEvent(event: .error(error: error)) } } @@ -911,6 +1116,7 @@ private class Assignment: Equatable { var fullOn: Bool = false var custom: Bool = false var audienceMismatch = false - var variables: [String: JSON] = [:] + var variables: [String: JSON]? var exposed = ManagedAtomic(false) + var attrsSeq: Int = 0 } diff --git a/Sources/ABSmartly/ContextConfig.swift b/Sources/ABSmartly/ContextConfig.swift index 0a899eb..e9f78b2 100644 --- a/Sources/ABSmartly/ContextConfig.swift +++ b/Sources/ABSmartly/ContextConfig.swift @@ -17,7 +17,9 @@ public class ContextConfig { } public func setUnits(units: [String: String]) { - units.forEach { setUnit(unitType: $0.key, uid: $0.value) } + for (unitType, uid) in units { + setUnit(unitType: unitType, uid: uid) + } } public func setAttribute(name: String, value: JSON) { @@ -25,7 +27,9 @@ public class ContextConfig { } public func setAttributes(attributes: [String: JSON]) { - attributes.forEach { setAttribute(name: $0.key, value: $0.value) } + for (name, value) in attributes { + setAttribute(name: name, value: value) + } } public func setOverride(experimentName: String, variant: Int) { @@ -33,7 +37,9 @@ public class ContextConfig { } public func setOverrides(overrides: [String: Int]) { - overrides.forEach { setOverride(experimentName: $0.key, variant: $0.value) } + for (experimentName, variant) in overrides { + setOverride(experimentName: experimentName, variant: variant) + } } public func setCustomAssignment(experimentName: String, variant: Int) { @@ -41,6 +47,8 @@ public class ContextConfig { } public func setCustomAssignments(assignments: [String: Int]) { - assignments.forEach { setCustomAssignment(experimentName: $0.key, variant: $0.value) } + for (experimentName, variant) in assignments { + setCustomAssignment(experimentName: experimentName, variant: variant) + } } } diff --git a/Sources/ABSmartly/ContextData.swift b/Sources/ABSmartly/ContextData.swift index dff59a1..7e2c899 100644 --- a/Sources/ABSmartly/ContextData.swift +++ b/Sources/ABSmartly/ContextData.swift @@ -16,16 +16,18 @@ public final class ContextData: Codable { } public init(from decoder: Decoder) throws { - if let container = try? decoder.container(keyedBy: CodingKeys.self) { - if let experiments = try? container.decode([Experiment].self, forKey: .experiments) { - self.experiments = experiments - return - } + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.experiments = try container.decode([Experiment].self, forKey: .experiments) + } catch let error as DecodingError { + throw error + } catch { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Failed to decode ContextData: \(error.localizedDescription)", + underlyingError: error)) } - - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: [], debugDescription: "Experiments array couldn't be decoded from this data")) } } diff --git a/Sources/ABSmartly/DefaultClient.swift b/Sources/ABSmartly/DefaultClient.swift index f0cbf32..ccaafe8 100644 --- a/Sources/ABSmartly/DefaultClient.swift +++ b/Sources/ABSmartly/DefaultClient.swift @@ -40,20 +40,24 @@ public final class DefaultClient: Client { "X-API-Key": config.apiKey, "X-Environment": config.environment, "X-Application": config.application, - "X-Application-Version": "0", + "X-Application-Version": config.applicationVersion, ] } public func getContextData() -> Promise { return Promise { seal in - httpClient.get(url: url, query: getQuery, headers: nil).done { response in + httpClient.get(url: url, query: getQuery, headers: nil).done(on: DispatchQueue.global()) { response in + guard (200...299).contains(response.status) else { + seal.reject(ABSmartlyHTTPError(response.status, response.statusMessage)) + return + } do { let result = try JSONDecoder().decode(ContextData.self, from: response.content) seal.fulfill(result) } catch { seal.reject(error) } - }.catch { error in + }.catch(on: DispatchQueue.global()) { error in seal.reject(error) } } @@ -63,9 +67,13 @@ public final class DefaultClient: Client { return Promise { seal in do { let data = try JSONEncoder().encode(event) - httpClient.put(url: url, query: nil, headers: putHeaders, body: data).done { response in + httpClient.put(url: url, query: nil, headers: putHeaders, body: data).done(on: DispatchQueue.global()) { response in + guard (200...299).contains(response.status) else { + seal.reject(ABSmartlyHTTPError(response.status, response.statusMessage)) + return + } seal.fulfill(()) - }.catch { error in + }.catch(on: DispatchQueue.global()) { error in seal.reject(error) } } catch { diff --git a/Sources/ABSmartly/DefaultContextEventHandler.swift b/Sources/ABSmartly/DefaultContextEventHandler.swift index 251da2d..e89ef3d 100644 --- a/Sources/ABSmartly/DefaultContextEventHandler.swift +++ b/Sources/ABSmartly/DefaultContextEventHandler.swift @@ -1,14 +1,7 @@ import Foundation import PromiseKit -public class DefaultContextEventHandler: ContextEventHandler { - private let client: Client - - public init(client: Client) { - self.client = client - } - - public func publish(event: PublishEvent) -> Promise { - return client.publish(event: event) - } +/// Deprecated: Use DefaultContextPublisher instead. +@available(*, deprecated, renamed: "DefaultContextPublisher") +public class DefaultContextEventHandler: DefaultContextPublisher { } diff --git a/Sources/ABSmartly/DefaultContextPublisher.swift b/Sources/ABSmartly/DefaultContextPublisher.swift new file mode 100644 index 0000000..e442405 --- /dev/null +++ b/Sources/ABSmartly/DefaultContextPublisher.swift @@ -0,0 +1,14 @@ +import Foundation +import PromiseKit + +public class DefaultContextPublisher: ContextPublisher { + private let client: Client + + public init(client: Client) { + self.client = client + } + + public func publish(event: PublishEvent) -> Promise { + return client.publish(event: event) + } +} diff --git a/Sources/ABSmartly/DefaultHTTPClient.swift b/Sources/ABSmartly/DefaultHTTPClient.swift index e5da573..0cc3db8 100644 --- a/Sources/ABSmartly/DefaultHTTPClient.swift +++ b/Sources/ABSmartly/DefaultHTTPClient.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif import PromiseKit +import Atomics public class DefaultHTTPResponse: Response { public init(status: Int, statusMessage: String, contentType: String, content: Data) { @@ -17,16 +21,33 @@ public class DefaultHTTPResponse: Response { public class DefaultHTTPClient: HTTPClient { private var config: DefaultHTTPClientConfig = DefaultHTTPClientConfig() - private var session: URLSession + private var session: URLSession? + private let sessionLock = NSLock() public init(config: DefaultHTTPClientConfig) { self.config = config let sessionConfig = URLSessionConfiguration.ephemeral sessionConfig.timeoutIntervalForRequest = config.connectionRequestTimeout sessionConfig.timeoutIntervalForResource = config.connectionResourceTimeout + sessionConfig.httpMaximumConnectionsPerHost = 4 self.session = URLSession(configuration: sessionConfig) } + deinit { + sessionLock.lock() + let s = session + session = nil + sessionLock.unlock() + #if canImport(FoundationNetworking) + // Skip URLSession invalidation on Linux: swift-corelibs-foundation has a known bug + // where invalidateAndCancel/finishTasksAndInvalidate during deinit causes a crash + // in the dispatch queue teardown. The session will be cleaned up by ARC. + _ = s + #else + s?.invalidateAndCancel() + #endif + } + public func get(url: String, query: [String: String]?, headers: [String: String]?) -> Promise { return request(method: "GET", url: url, query: query, headers: headers, body: nil) } @@ -45,33 +66,52 @@ public class DefaultHTTPClient: HTTPClient { public func request(method: String, url: String, query: [String: String]?, headers: [String: String]?, body: Data?) -> Promise { - return retry( + return Self.retry( times: config.retries, delay: config.retryInterval, - body: { attempt in + body: { [weak self] attempt in return Promise { seal in + guard let self = self else { + seal.reject(URLError(.cancelled)) + return + } + + self.sessionLock.lock() + let capturedSession = self.session + self.sessionLock.unlock() + guard let session = capturedSession else { + seal.reject(ABSmartlyError("HTTP client is closed")) + return + } + guard var components = URLComponents(string: url) else { - throw URLError(.badURL) + seal.reject(URLError(.badURL)) + return } - if query != nil { - components.queryItems = query!.compactMap { (key, value) in + if let query = query { + components.queryItems = query.compactMap { (key, value) in URLQueryItem(name: key, value: value) } } - var request = URLRequest(url: components.url!) + guard let requestURL = components.url else { + seal.reject(URLError(.badURL)) + return + } + + var request = URLRequest(url: requestURL) request.httpMethod = method request.timeoutInterval = self.config.connectionResourceTimeout - if headers != nil { + if let headers = headers { request.allHTTPHeaderFields = headers } - if method != "GET" && body != nil { + if method != "GET", let body = body { request.httpBody = body } - self.session.dataTask( + session.dataTask( with: request, completionHandler: { data, rsp, error in if let data = data, let rsp = rsp as? HTTPURLResponse { @@ -103,20 +143,26 @@ public class DefaultHTTPClient: HTTPClient { } public func close() -> Promise { + sessionLock.lock() + session?.finishTasksAndInvalidate() + session = nil + sessionLock.unlock() return Promise.value(()) } } -func retry(times: UInt, delay: TimeInterval, body: @escaping (UInt) -> Promise) -> Promise { - var tryCounter: UInt = 0 - func attempt() -> Promise { - tryCounter += 1 - return body(tryCounter).recover(policy: CatchPolicy.allErrorsExceptCancellation) { error -> Promise in - guard tryCounter <= times else { - throw error +extension DefaultHTTPClient { + static func retry(times: UInt, delay: TimeInterval, body: @escaping (UInt) -> Promise) -> Promise { + let tryCounter = ManagedAtomic(0) + func attempt() -> Promise { + let currentTry = tryCounter.wrappingIncrementThenLoad(ordering: .acquiringAndReleasing) + return body(currentTry).recover(policy: CatchPolicy.allErrorsExceptCancellation) { error -> Promise in + guard currentTry <= times else { + throw error + } + return after(seconds: delay).then(attempt) } - return after(seconds: delay).then(attempt) } + return attempt() } - return attempt() } diff --git a/Sources/ABSmartly/DefaultScheduler.swift b/Sources/ABSmartly/DefaultScheduler.swift index fb7ffa0..89aa812 100644 --- a/Sources/ABSmartly/DefaultScheduler.swift +++ b/Sources/ABSmartly/DefaultScheduler.swift @@ -1,32 +1,43 @@ import Foundation public class DefaultScheduledHandle: ScheduledHandle { + private var handle: DispatchSourceTimer? + private var cancelled = false + private let lock = NSLock() + public func cancel() { - if handle != nil { - handle!.cancel() - } + lock.lock() + defer { lock.unlock() } + + guard let timer = handle, !cancelled else { return } + cancelled = true + timer.cancel() + handle = nil } public func isCancelled() -> Bool { - if handle != nil { - return handle!.isCancelled - } - return false + lock.lock() + defer { lock.unlock() } + return cancelled } public init(handle: DispatchSourceTimer) { self.handle = handle } - private let handle: DispatchSourceTimer? + deinit { + cancel() + } } public class DefaultScheduler: Scheduler { + private let timerQueue = DispatchQueue(label: "com.absmartly.scheduler", qos: .utility) + public init() {} public func schedule(after: TimeInterval, execute: @escaping Work) -> ScheduledHandle { - let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main) - timer.setEventHandler(qos: .background, handler: execute) + let timer = DispatchSource.makeTimerSource(queue: timerQueue) + timer.setEventHandler(qos: .utility, handler: execute) timer.schedule(deadline: .now() + after, leeway: .milliseconds(5)) timer.resume() @@ -36,15 +47,8 @@ public class DefaultScheduler: Scheduler { public func scheduleWithFixedDelay(after: TimeInterval, repeating: TimeInterval, execute: @escaping Work) -> ScheduledHandle { - let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main) - timer.setEventHandler( - qos: .background, - handler: { - timer.suspend() - execute() - timer.resume() - }) - + let timer = DispatchSource.makeTimerSource(queue: timerQueue) + timer.setEventHandler(qos: .utility, handler: execute) timer.schedule(deadline: .now() + after, repeating: repeating, leeway: .milliseconds(5)) timer.resume() diff --git a/Sources/ABSmartly/DefaultVariableParser.swift b/Sources/ABSmartly/DefaultVariableParser.swift index 3eb7a3c..1635e89 100644 --- a/Sources/ABSmartly/DefaultVariableParser.swift +++ b/Sources/ABSmartly/DefaultVariableParser.swift @@ -7,10 +7,17 @@ public class DefaultVariableParser: VariableParser { let data = Data(config.utf8) do { let parsed = try JSON(data: data, options: .mutableContainers) - return parsed.dictionary + if let dictionary = parsed.dictionary { + return dictionary + } else { + let truncated = config.count > 100 ? "\(config.prefix(100))..." : config + Logger.error("Variant config for experiment '\(experimentName)' is not a valid JSON object. Config: '\(truncated)'") + return nil + } } catch { - Logger.error(error.localizedDescription) + let truncated = config.count > 100 ? "\(config.prefix(100))..." : config + Logger.error("Failed to parse variant config for experiment '\(experimentName)': \(error.localizedDescription). Config: '\(truncated)'") + return nil } - return nil } } diff --git a/Sources/ABSmartly/Experiment.swift b/Sources/ABSmartly/Experiment.swift index e433065..a9af9db 100644 --- a/Sources/ABSmartly/Experiment.swift +++ b/Sources/ABSmartly/Experiment.swift @@ -19,37 +19,38 @@ public struct Experiment: Codable { public let customFieldValues: [CustomFieldValue]? public init(from decoder: Decoder) throws { - guard let container = try? decoder.container(keyedBy: CodingKeys.self) else { - throw DecodingError.dataCorrupted( - DecodingError.Context(codingPath: [], debugDescription: "Experiment couldn't be decoded from this data") - ) - } + let container = try decoder.container(keyedBy: CodingKeys.self) - id = (try? container.decodeIfPresent(Int.self, forKey: .id)) ?? 0 + name = try container.decode(String.self, forKey: .name) do { - name = try container.decode(String.self, forKey: .name) - } catch { - throw error - } + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + unitType = try container.decodeIfPresent(String.self, forKey: .unitType) + iteration = try container.decodeIfPresent(Int.self, forKey: .iteration) ?? 0 + seedHi = try container.decodeIfPresent(Int.self, forKey: .seedHi) ?? 0 + seedLo = try container.decodeIfPresent(Int.self, forKey: .seedLo) ?? 0 - unitType = (try? container.decodeIfPresent(String.self, forKey: .unitType)) ?? nil - iteration = (try? container.decodeIfPresent(Int.self, forKey: .iteration)) ?? 0 - seedHi = (try? container.decodeIfPresent(Int.self, forKey: .seedHi)) ?? 0 - seedLo = (try? container.decodeIfPresent(Int.self, forKey: .seedLo)) ?? 0 + split = try container.decodeIfPresent([Double].self, forKey: .split) ?? [] + trafficSeedHi = try container.decodeIfPresent(Int.self, forKey: .trafficSeedHi) ?? 0 + trafficSeedLo = try container.decodeIfPresent(Int.self, forKey: .trafficSeedLo) ?? 0 - split = (try? container.decodeIfPresent([Double].self, forKey: .split)) ?? [] - trafficSeedHi = (try? container.decodeIfPresent(Int.self, forKey: .trafficSeedHi)) ?? 0 - trafficSeedLo = (try? container.decodeIfPresent(Int.self, forKey: .trafficSeedLo)) ?? 0 + trafficSplit = try container.decodeIfPresent([Double].self, forKey: .trafficSplit) ?? [] + fullOnVariant = try container.decodeIfPresent(Int.self, forKey: .fullOnVariant) ?? 0 + audienceStrict = try container.decodeIfPresent(Bool.self, forKey: .audienceStrict) ?? false + audience = try container.decodeIfPresent(String.self, forKey: .audience) - trafficSplit = (try? container.decodeIfPresent([Double].self, forKey: .trafficSplit)) ?? [] - fullOnVariant = (try? container.decodeIfPresent(Int.self, forKey: .fullOnVariant)) ?? 0 - audienceStrict = (try? container.decodeIfPresent(Bool.self, forKey: .audienceStrict)) ?? false - audience = (try? container.decodeIfPresent(String.self, forKey: .audience)) - - applications = (try? container.decode([Application].self, forKey: .applications)) ?? [] - variants = (try? container.decode([ExperimentVariant].self, forKey: .variants)) ?? [] - customFieldValues = (try? container.decode([CustomFieldValue].self, forKey: .customFieldValues)) ?? [] + applications = try container.decodeIfPresent([Application].self, forKey: .applications) + variants = try container.decode([ExperimentVariant].self, forKey: .variants) + customFieldValues = try container.decodeIfPresent([CustomFieldValue].self, forKey: .customFieldValues) + } catch let error as DecodingError { + throw error + } catch { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Failed to decode Experiment '\(name)': \(error.localizedDescription)", + underlyingError: error)) + } } } diff --git a/Sources/ABSmartly/Internal/Hashing/Buffers.swift b/Sources/ABSmartly/Internal/Hashing/Buffers.swift index fddbe9d..273b0bf 100644 --- a/Sources/ABSmartly/Internal/Hashing/Buffers.swift +++ b/Sources/ABSmartly/Internal/Hashing/Buffers.swift @@ -14,10 +14,7 @@ class Buffers { } static func putUInt32(_ buf: inout [UInt8], _ offset: Int, _ x: Int) { - buf[offset] = (UInt8)(x & 0xff) - buf[offset + 1] = (UInt8)((x >> 8) & 0xff) - buf[offset + 2] = (UInt8)((x >> 16) & 0xff) - buf[offset + 3] = (UInt8)((x >> 24) & 0xff) + putUInt32(&buf, offset, UInt32(truncatingIfNeeded: x)) } static func putUInt32(_ buf: inout [UInt8], _ offset: Int, _ x: UInt32) { @@ -32,11 +29,6 @@ class Buffers { | (UInt32(buf[offset + 2] & 0xff) << 16) | (UInt32(buf[offset + 3] & 0xff) << 24) } - static func getUInt24(_ buf: [UInt8], _ offset: Int) -> UInt32 { - return (UInt32(buf[offset] & 0xff)) | (UInt32(buf[offset + 1] & 0xff) << 8) - | (UInt32(buf[offset + 2] & 0xff) << 16) - } - static func getUInt16(_ buf: [UInt8], _ offset: Int) -> UInt16 { return (UInt16(buf[offset] & 0xff)) | (UInt16(buf[offset + 1] & 0xff) << 8) } @@ -46,9 +38,9 @@ class Buffers { } static func encodeUTF8(_ buf: inout [UInt8], _ offset: Int, _ value: String) -> Int { - let stringUTF8: [UInt8] = Array(value.utf8) + let stringUTF8 = encodeUTF8(value) - for i in 0...stringUTF8.count { + for i in 0.. String { let data = Data(string.utf8) + + #if canImport(CommonCrypto) + // Apple platforms let md5 = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> [UInt8] in var hash = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH)) CC_MD5(bytes.baseAddress, CC_LONG(data.count), &hash) return hash } + #else + // Linux with Swift Crypto + let digest = Insecure.MD5.hash(data: data) + let md5 = Array(digest) + #endif let base64Str = Data(md5).base64EncodedString() @@ -25,11 +41,11 @@ class Hashing { return base64url } - static func hash(_ unit: String) -> String { + public static func hash(_ unit: String) -> String { return MD5Base64Url(unit) } - static func hash(_ unit: String) -> [UInt8] { + static func hashBytes(_ unit: String) -> [UInt8] { return Array(MD5Base64Url(unit).utf8) } } diff --git a/Sources/ABSmartly/Internal/Hashing/MurmurHash.swift b/Sources/ABSmartly/Internal/Hashing/MurmurHash.swift index 4abdfe2..3b21b35 100644 --- a/Sources/ABSmartly/Internal/Hashing/MurmurHash.swift +++ b/Sources/ABSmartly/Internal/Hashing/MurmurHash.swift @@ -41,21 +41,19 @@ class MurmurHash { let remaining = byteCount & 3 if remaining != 0 { + var tail = UInt32(0) switch remaining { case 3: - let k = scramble(Buffers.getUInt32(bytes, i)) - hash ^= k - break + tail ^= UInt32(Buffers.getUInt8(bytes, i + 2)) << 16 + fallthrough case 2: - let k = scramble(UInt32(Buffers.getUInt16(bytes, i))) - hash ^= k - break + tail ^= UInt32(Buffers.getUInt8(bytes, i + 1)) << 8 + fallthrough case 1: - let k = scramble(UInt32(Buffers.getUInt8(bytes, i))) - hash ^= k - break + tail ^= UInt32(Buffers.getUInt8(bytes, i)) + hash ^= scramble(tail) default: break @@ -74,14 +72,4 @@ class MurmurHash { return hash } - - private static func updateInternal(_ hashIn: UInt32, _ value: UInt32) -> UInt32 { - let k = scramble(value) - var hash = hashIn - hash = hash ^ k - hash = (hash << r2) | (hash >> (32 - r2)) - hash = hash &* m &+ n - - return hash - } } diff --git a/Sources/ABSmartly/JsonExpr/ExprEvaluator.swift b/Sources/ABSmartly/JsonExpr/ExprEvaluator.swift index 73a61e6..f858b2a 100644 --- a/Sources/ABSmartly/JsonExpr/ExprEvaluator.swift +++ b/Sources/ABSmartly/JsonExpr/ExprEvaluator.swift @@ -73,10 +73,9 @@ final class ExprEvaluator: Evaluator { case .bool: return JSON(x.boolValue ? "true" : "false") case .number: - if let string = formatter.string(from: x.number!) { + if let number = x.number, let string = formatter.string(from: number) { return JSON(string) } - break default: break } @@ -95,10 +94,8 @@ final class ExprEvaluator: Evaluator { if let index = Int(frag) { value = target[index] } - break case .dictionary: value = target[String(frag)] - break default: break } diff --git a/Sources/ABSmartly/JsonExpr/Operators/MatchOperator.swift b/Sources/ABSmartly/JsonExpr/Operators/MatchOperator.swift index 5a8d651..1e38625 100644 --- a/Sources/ABSmartly/JsonExpr/Operators/MatchOperator.swift +++ b/Sources/ABSmartly/JsonExpr/Operators/MatchOperator.swift @@ -1,6 +1,9 @@ import Foundation final class MatchOperator: BinaryOperator { + private static let maxPatternLength = 1000 + private static let maxInputLength = 10000 + override func binary(_ evaluator: Evaluator, _ lhs: JSON, _ rhs: JSON) -> JSON { let text = evaluator.stringConvert(lhs) if text.type != .null { @@ -11,15 +14,49 @@ final class MatchOperator: BinaryOperator { return JSON(true) } - if let matcher = try? NSRegularExpression(pattern: regex) { - let string = text.stringValue - if let _ = matcher.firstMatch(in: string, range: NSRange(location: 0, length: string.count)) { - return JSON(true) - } - return JSON(false) + guard regex.count <= Self.maxPatternLength else { + Logger.error("Regex pattern exceeds maximum length of \(Self.maxPatternLength): '\(regex.prefix(50))...'") + return JSON.null + } + + let string = text.stringValue + guard string.count <= Self.maxInputLength else { + Logger.error("Input string exceeds maximum length of \(Self.maxInputLength)") + return JSON.null + } + + if hasNestedQuantifiers(regex) { + Logger.error("Regex pattern contains potentially catastrophic nested quantifiers: '\(regex)'") + return JSON.null + } + + do { + let matcher = try NSRegularExpression(pattern: regex, options: []) + let range = NSRange(string.startIndex..., in: string) + return JSON(matcher.firstMatch(in: string, range: range) != nil) + } catch { + Logger.error("Failed to compile regex pattern '\(regex)': \(error.localizedDescription)") + return JSON.null } } } return JSON.null } + + private func hasNestedQuantifiers(_ pattern: String) -> Bool { + let dangerousPatterns = [ + "\\(.*[+*].*\\).*[+*]", + "\\(.*[+*].*\\).*\\{", + "\\{.*\\}.*[+*]", + "\\{.*\\}.*\\{" + ] + + for dangerous in dangerousPatterns { + if let _ = try? NSRegularExpression(pattern: dangerous, options: []) + .firstMatch(in: pattern, range: NSRange(pattern.startIndex..., in: pattern)) { + return true + } + } + return false + } } diff --git a/Sources/ABSmartly/JsonExpr/Operators/OrCombinator.swift b/Sources/ABSmartly/JsonExpr/Operators/OrCombinator.swift index efe00a0..04e8b35 100644 --- a/Sources/ABSmartly/JsonExpr/Operators/OrCombinator.swift +++ b/Sources/ABSmartly/JsonExpr/Operators/OrCombinator.swift @@ -1,6 +1,6 @@ import Foundation -class OrCombinator: BooleanCombinator { +final class OrCombinator: BooleanCombinator { override func combine(_ evaluator: Evaluator, _ args: JSON) -> JSON { for (_, arg): (String, JSON) in args { if evaluator.booleanConvert(evaluator.evaluate(arg)).boolValue { diff --git a/Sources/ABSmartly/Logger.swift b/Sources/ABSmartly/Logger.swift index a2a1065..b9343b7 100644 --- a/Sources/ABSmartly/Logger.swift +++ b/Sources/ABSmartly/Logger.swift @@ -1,8 +1,11 @@ import Foundation +#if canImport(OSLog) import OSLog +#endif class Logger { static func error(_ error: String) { + #if canImport(OSLog) if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { let customLog = os.Logger(subsystem: "ABSmartly", category: "") customLog.error("\(error)") @@ -12,9 +15,13 @@ class Logger { } else { print("ABSmartly Error: " + error) } + #else + print("ABSmartly Error: " + error) + #endif } static func notice(_ note: String) { + #if canImport(OSLog) if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { let customLog = os.Logger(subsystem: "ABSmartly", category: "") customLog.notice("\(note)") @@ -24,5 +31,8 @@ class Logger { } else { print("ABSmartly Note: " + note) } + #else + print("ABSmartly Note: " + note) + #endif } } diff --git a/Sources/ABSmartly/Protocols/ContextEventHandler.swift b/Sources/ABSmartly/Protocols/ContextEventHandler.swift index c263ec6..f984125 100644 --- a/Sources/ABSmartly/Protocols/ContextEventHandler.swift +++ b/Sources/ABSmartly/Protocols/ContextEventHandler.swift @@ -1,7 +1,6 @@ import Foundation import PromiseKit -// sourcery: AutoMockable -public protocol ContextEventHandler { - func publish(event: PublishEvent) -> Promise -} +/// Deprecated: Use ContextPublisher instead. +@available(*, deprecated, renamed: "ContextPublisher") +public typealias ContextEventHandler = ContextPublisher diff --git a/Sources/ABSmartly/Protocols/ContextPublisher.swift b/Sources/ABSmartly/Protocols/ContextPublisher.swift new file mode 100644 index 0000000..c36ea6a --- /dev/null +++ b/Sources/ABSmartly/Protocols/ContextPublisher.swift @@ -0,0 +1,7 @@ +import Foundation +import PromiseKit + +// sourcery: AutoMockable +public protocol ContextPublisher { + func publish(event: PublishEvent) -> Promise +} diff --git a/Sources/ABSmartly/VariantAssigner.swift b/Sources/ABSmartly/VariantAssigner.swift index d93b4d1..0785233 100644 --- a/Sources/ABSmartly/VariantAssigner.swift +++ b/Sources/ABSmartly/VariantAssigner.swift @@ -27,8 +27,8 @@ class VariantAssigner { static func chooseVariant(_ split: [Double], _ prob: Double) -> Int { var cumSum: Double = 0 - for (i, _) in split.enumerated() { - cumSum += split[i] + for (i, splitValue) in split.enumerated() { + cumSum += splitValue if prob < cumSum { return i diff --git a/Tests/ABSmartlyTests/ABSmartlySDKTest.swift b/Tests/ABSmartlyTests/ABSmartlySDKTest.swift index ef223fd..d513eb3 100644 --- a/Tests/ABSmartlyTests/ABSmartlySDKTest.swift +++ b/Tests/ABSmartlyTests/ABSmartlySDKTest.swift @@ -4,31 +4,31 @@ import XCTest @testable import ABSmartly -final class ABSmartlySDKTest: XCTestCase { - var sdk: ABSmartlySDK? +final class ABsmartlySDKTest: XCTestCase { + var sdk: ABsmartlySDK? var client: ClientMock? var contextConfig = ContextConfig() - func setUpSDK(block: ((ABSmartlyConfig) -> Void)? = nil) { + func setUpSDK(block: ((ABsmartlyConfig) -> Void)? = nil) { contextConfig = ContextConfig() contextConfig.setUnit(unitType: "session_id", uid: "123456789") client = ClientMock() do { - let sdkConfig = ABSmartlyConfig(client: client!) + let sdkConfig = ABsmartlyConfig(client: client!) if let block = block { block(sdkConfig) } - sdk = try ABSmartlySDK(config: sdkConfig) + sdk = try ABsmartlySDK(config: sdkConfig) } catch { XCTFail(error.localizedDescription) } } func testThrowsWithInvalidConfig() { - let config = ABSmartlyConfig() + let config = ABsmartlyConfig() - XCTAssertThrowsError(try ABSmartlySDK(config: config)) { error in + XCTAssertThrowsError(try ABsmartlySDK(config: config)) { error in XCTAssertEqual(error.localizedDescription, "Missing Client instance") } } @@ -159,4 +159,125 @@ final class ABSmartlySDKTest: XCTestCase { wait(for: [expectation], timeout: 1.0) } + + func testNamedParameterInitialization() throws { + let sdk = try ABsmartlySDK( + endpoint: "https://test.absmartly.io/v1", + apiKey: "test-api-key", + application: "test-app", + environment: "test" + ) + + XCTAssertNotNil(sdk) + + let contextConfig = ContextConfig() + contextConfig.setUnit(unitType: "session_id", uid: "test123") + let context = sdk.createContext(config: contextConfig) + XCTAssertNotNil(context) + } + + func testNamedParameterInitializationWithOptionalParameters() throws { + let sdk = try ABsmartlySDK( + endpoint: "https://test.absmartly.io/v1", + apiKey: "test-api-key", + application: "test-app", + environment: "production", + applicationVersion: "1.2.3", + timeout: 5.0, + retries: 3 + ) + + XCTAssertNotNil(sdk) + + let contextConfig = ContextConfig() + contextConfig.setUnit(unitType: "user_id", uid: "user456") + let context = sdk.createContext(config: contextConfig) + XCTAssertNotNil(context) + } + + func testNamedParameterInitializationThrowsWithEmptyEndpoint() { + XCTAssertThrowsError( + try ABsmartlySDK( + endpoint: "", + apiKey: "test-api-key", + application: "test-app", + environment: "test" + ) + ) { error in + XCTAssertEqual(error.localizedDescription, "Missing Endpoint configuration") + } + } + + func testNamedParameterInitializationThrowsWithEmptyApiKey() { + XCTAssertThrowsError( + try ABsmartlySDK( + endpoint: "https://test.absmartly.io/v1", + apiKey: "", + application: "test-app", + environment: "test" + ) + ) { error in + XCTAssertEqual(error.localizedDescription, "Missing APIKey configuration") + } + } + + func testNamedParameterInitializationThrowsWithEmptyApplication() { + XCTAssertThrowsError( + try ABsmartlySDK( + endpoint: "https://test.absmartly.io/v1", + apiKey: "test-api-key", + application: "", + environment: "test" + ) + ) { error in + XCTAssertEqual(error.localizedDescription, "Missing Application configuration") + } + } + + func testNamedParameterInitializationThrowsWithEmptyEnvironment() { + XCTAssertThrowsError( + try ABsmartlySDK( + endpoint: "https://test.absmartly.io/v1", + apiKey: "test-api-key", + application: "test-app", + environment: "" + ) + ) { error in + XCTAssertEqual(error.localizedDescription, "Missing Environment configuration") + } + } + + func testNamedParameterInitializationWithCustomEventLogger() throws { + let customLogger = ContextEventLoggerMock() + + let sdk = try ABsmartlySDK( + endpoint: "https://test.absmartly.io/v1", + apiKey: "test-api-key", + application: "test-app", + environment: "test", + contextEventLogger: customLogger + ) + + XCTAssertNotNil(sdk) + } + + func testBackwardsCompatibility() throws { + let clientConfig = ClientConfig( + apiKey: "test-key", + application: "test-app", + endpoint: "https://test.absmartly.io/v1", + environment: "test" + ) + + let client = try DefaultClient(config: clientConfig) + let sdkConfig = ABsmartlyConfig(client: client) + let sdk = try ABsmartlySDK(config: sdkConfig) + + XCTAssertNotNil(sdk) + + let contextConfig = ContextConfig() + contextConfig.setUnit(unitType: "session_id", uid: "test123") + let context = sdk.createContext(config: contextConfig) + XCTAssertNotNil(context) + } } diff --git a/Tests/ABSmartlyTests/ConcurrencyTests.swift b/Tests/ABSmartlyTests/ConcurrencyTests.swift new file mode 100644 index 0000000..cf29355 --- /dev/null +++ b/Tests/ABSmartlyTests/ConcurrencyTests.swift @@ -0,0 +1,261 @@ +import Foundation +import PromiseKit +import XCTest + +@testable import ABSmartly + +final class ConcurrencyTests: XCTestCase { + var provider: ContextDataProviderMock = ContextDataProviderMock() + var handler: ContextEventHandlerMock = ContextEventHandlerMock() + var logger: ContextEventLoggerMock = ContextEventLoggerMock() + var parser: VariableParser = DefaultVariableParser() + var scheduler: SchedulerMock = SchedulerMock() + var clock: ClockMock = ClockMock() + + let units = [ + "email": "bleh@absmartly.com", + "session_id": "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + "user_id": "123456789", + ] + + override func setUp() async throws { + provider = ContextDataProviderMock() + handler = ContextEventHandlerMock() + logger = ContextEventLoggerMock() + parser = DefaultVariableParser() + scheduler = SchedulerMock() + scheduler.scheduleAfterExecuteReturnValue = ScheduledHandleMock() + scheduler.scheduleWithFixedDelayAfterRepeatingExecuteReturnValue = ScheduledHandleMock() + clock.millisReturnValue = 1_620_000_000_000 + } + + func getContextData(source: String = "context") throws -> ContextData { + let path = TestResources.path(forResource: source, ofType: "json") + let data = try Foundation.Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) + return try JSONDecoder().decode(ContextData.self, from: data) + } + + func createContext(config: ContextConfig, data: Promise? = nil) throws -> Context { + let data = try data ?? Promise.value(try getContextData()) + return Context( + config: config, clock: clock, scheduler: scheduler, handler: handler, provider: provider, logger: logger, + parser: parser, matcher: AudienceMatcher(), + promise: data) + } + + func getContextConfig(withUnits: Bool = false) -> ContextConfig { + let contextConfig: ContextConfig = ContextConfig() + + if withUnits { + contextConfig.setUnits(units: units) + } + + return contextConfig + } + + func testConcurrentTreatmentAccess() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + let expectation = XCTestExpectation(description: "Concurrent treatment access completes") + expectation.expectedFulfillmentCount = 100 + + let concurrentQueue = DispatchQueue(label: "com.absmartly.concurrency.test", attributes: .concurrent) + let experimentNames = ["exp_test_ab", "exp_test_abc", "exp_test_fullon", "exp_test_not_eligible"] + + for i in 0..<100 { + concurrentQueue.async { + let experimentName = experimentNames[i % experimentNames.count] + let treatment = context.getTreatment(experimentName) + XCTAssertGreaterThanOrEqual(treatment, 0) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertTrue(context.getPendingCount() > 0) + } + + func testConcurrentGoalTracking() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + let expectation = XCTestExpectation(description: "Concurrent goal tracking completes") + expectation.expectedFulfillmentCount = 100 + + let concurrentQueue = DispatchQueue(label: "com.absmartly.goal.test", attributes: .concurrent) + let goalNames = ["goal_1", "goal_2", "goal_3", "goal_4", "goal_5"] + + for i in 0..<100 { + concurrentQueue.async { + let goalName = goalNames[i % goalNames.count] + context.track(goalName, properties: ["iteration": JSON(i), "timestamp": JSON(Date().timeIntervalSince1970)]) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertEqual(context.getPendingCount(), 100) + } + + func testRaceConditionStateChange() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let (promise, resolver) = Promise.pending() + let context = try createContext(config: contextConfig, data: promise) + + let expectation = XCTestExpectation(description: "Race condition state change completes") + expectation.expectedFulfillmentCount = 51 + + let concurrentQueue = DispatchQueue(label: "com.absmartly.state.test", attributes: .concurrent) + + for _ in 0..<50 { + concurrentQueue.async { + context.track("goal_during_init", properties: nil) + expectation.fulfill() + } + } + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + do { + resolver.fulfill(try self.getContextData()) + } catch { + XCTFail("Failed to load context data: \(error)") + } + } + + _ = context.waitUntilReady().done { ctx in + XCTAssertTrue(ctx.isReady()) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertTrue(context.isReady()) + XCTAssertEqual(context.getPendingCount(), 50) + } + + func testConcurrentRefreshAndPublish() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + context.track("test_goal", properties: nil) + _ = context.getTreatment("exp_test_ab") + + let refreshExpectation = XCTestExpectation(description: "Refresh completes") + let publishExpectation = XCTestExpectation(description: "Publish completes") + + let refreshedContextData = try getContextData(source: "refreshed") + provider.getContextDataReturnValue = Promise.value(refreshedContextData) + + handler.publishEventReturnValue = Promise.value(()) + + let concurrentQueue = DispatchQueue(label: "com.absmartly.refresh.test", attributes: .concurrent) + + concurrentQueue.async { + _ = context.refresh().done { + refreshExpectation.fulfill() + } + } + + concurrentQueue.async { + _ = context.publish().done { + publishExpectation.fulfill() + } + } + + wait(for: [refreshExpectation, publishExpectation], timeout: 10.0) + + XCTAssertTrue(context.isReady()) + XCTAssertFalse(context.isFailed()) + } + + func testAsyncAwaitEdgeCases() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let (promise, resolver) = Promise.pending() + let context = try createContext(config: contextConfig, data: promise) + + let expectation = XCTestExpectation(description: "Async await edge cases complete") + + XCTAssertFalse(context.isReady()) + + let treatment1 = context.peekTreatment("exp_test_ab") + XCTAssertEqual(treatment1, 0) + + context.setOverride(experimentName: "exp_test_override", variant: 5) + context.setAttribute(name: "test_attr", value: JSON("test_value")) + + resolver.fulfill(try getContextData()) + + _ = context.waitUntilReady().done { ctx in + let treatment2 = ctx.getTreatment("exp_test_ab") + XCTAssertEqual(treatment2, 1) + + XCTAssertEqual(ctx.getOverride(experimentName: "exp_test_override"), 5) + XCTAssertEqual(ctx.getAttribute(name: "test_attr"), JSON("test_value")) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testConcurrentSetAndGetUnits() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: false) + let context = try createContext(config: contextConfig) + + let expectation = XCTestExpectation(description: "Concurrent unit operations complete") + expectation.expectedFulfillmentCount = 100 + + let concurrentQueue = DispatchQueue(label: "com.absmartly.units.test", attributes: .concurrent) + + for i in 0..<50 { + concurrentQueue.async { + context.setUnit(unitType: "user_\(i)", uid: "uid_\(i)") + expectation.fulfill() + } + } + + for _ in 0..<50 { + concurrentQueue.async { + _ = context.getUnits() + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 10.0) + + let units = context.getUnits() + XCTAssertEqual(units.count, 50) + } + + func testConcurrentAttributeAccess() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + let expectation = XCTestExpectation(description: "Concurrent attribute access completes") + expectation.expectedFulfillmentCount = 200 + + let concurrentQueue = DispatchQueue(label: "com.absmartly.attrs.test", attributes: .concurrent) + + for i in 0..<100 { + concurrentQueue.async { + context.setAttribute(name: "attr_\(i)", value: JSON("value_\(i)")) + expectation.fulfill() + } + } + + for _ in 0..<100 { + concurrentQueue.async { + _ = context.getAttributes() + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 10.0) + + let attrs = context.getAttributes() + XCTAssertEqual(attrs.count, 100) + } +} diff --git a/Tests/ABSmartlyTests/ContextDataDeserializerTest.swift b/Tests/ABSmartlyTests/ContextDataDeserializerTest.swift index d76cd7d..94db408 100644 --- a/Tests/ABSmartlyTests/ContextDataDeserializerTest.swift +++ b/Tests/ABSmartlyTests/ContextDataDeserializerTest.swift @@ -4,7 +4,7 @@ import XCTest final class ContextDataDeserializerTest: XCTestCase { func testContextDataDeserialization() throws { - let path = Bundle.module.path(forResource: "context", ofType: "json", inDirectory: "Resources")! + let path = TestResources.path(forResource: "context", ofType: "json") let data = try Foundation.Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) let contextData = try JSONDecoder().decode(ContextData.self, from: data) diff --git a/Tests/ABSmartlyTests/ContextTest.swift b/Tests/ABSmartlyTests/ContextTest.swift index 54c308e..6da9a82 100644 --- a/Tests/ABSmartlyTests/ContextTest.swift +++ b/Tests/ABSmartlyTests/ContextTest.swift @@ -63,7 +63,7 @@ final class ContextTest: XCTestCase { ].sorted(by: { $0.type != $1.type ? $0.type < $1.type : $0.uid < $0.uid }) func getContextData(source: String = "context") throws -> ContextData { - let path = Bundle.module.path(forResource: source, ofType: "json", inDirectory: "Resources")! + let path = TestResources.path(forResource: source, ofType: "json") let data = try Foundation.Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) return try JSONDecoder().decode(ContextData.self, from: data) } @@ -146,6 +146,40 @@ final class ContextTest: XCTestCase { wait(for: [expectation], timeout: 1.0) } + func testReadyErrorReturnsNilOnSuccess() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + XCTAssertNil(context.readyError()) + } + + func testReadyErrorReturnsErrorOnFulfilledFailure() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let error = ABSmartlyError("test") + let context = try createContext( + config: contextConfig, data: Promise.init(error: error)) + XCTAssertTrue(context.isFailed()) + XCTAssertNotNil(context.readyError()) + } + + func testReadyErrorReturnsErrorOnAsyncFailure() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let (promise, resolver) = Promise.pending() + let context = try createContext(config: contextConfig, data: promise) + + let expectation = XCTestExpectation() + let error = ABSmartlyError("test") + + _ = context.waitUntilReady().done { ctx in + XCTAssertTrue(ctx.isFailed()) + XCTAssertNotNil(ctx.readyError()) + expectation.fulfill() + } + + resolver.reject(error) + + wait(for: [expectation], timeout: 1.0) + } + func testCallsEventLoggerWhenReady() throws { let contextConfig: ContextConfig = getContextConfig(withUnits: true) let (promise, resolver) = Promise.pending() @@ -461,20 +495,20 @@ final class ContextTest: XCTestCase { let overrides: [String: Int] = ["exp_test_new": 3, "exp_test_new_2": 5] context.setOverrides(overrides) - overrides.forEach { XCTAssertEqual($0.value, context.getTreatment($0.key)) } + for (key, value) in overrides { XCTAssertEqual(value, context.getTreatment(key)) } XCTAssertEqual(UInt(overrides.count), context.getPendingCount()) // overriding again with the same variant shouldn't clear assignment cache - overrides.forEach { - context.setOverride(experimentName: $0.key, variant: $0.value) - XCTAssertEqual($0.value, context.getTreatment($0.key)) + for (key, value) in overrides { + context.setOverride(experimentName: key, variant: value) + XCTAssertEqual(value, context.getTreatment(key)) } XCTAssertEqual(UInt(overrides.count), context.getPendingCount()) // overriding with the different variant should clear assignment cache - overrides.forEach { - context.setOverride(experimentName: $0.key, variant: $0.value + 11) - XCTAssertEqual($0.value + 11, context.getTreatment($0.key)) + for (key, value) in overrides { + context.setOverride(experimentName: key, variant: value + 11) + XCTAssertEqual(value + 11, context.getTreatment(key)) } XCTAssertEqual(2 * UInt(overrides.count), context.getPendingCount()) @@ -544,28 +578,28 @@ final class ContextTest: XCTestCase { let cassignments: [String: Int] = ["exp_test_ab": 2, "exp_test_abc": 3] - cassignments.forEach { XCTAssertEqual(expectedVariants[$0.key], context.getTreatment($0.key)) } + for (key, _) in cassignments { XCTAssertEqual(expectedVariants[key], context.getTreatment(key)) } XCTAssertEqual(UInt(cassignments.count), context.getPendingCount()) context.setCustomAssignments(cassignments) - cassignments.forEach { - context.setCustomAssignment(experimentName: $0.key, variant: $0.value) - XCTAssertEqual($0.value, context.getTreatment($0.key)) + for (key, value) in cassignments { + context.setCustomAssignment(experimentName: key, variant: value) + XCTAssertEqual(value, context.getTreatment(key)) } XCTAssertEqual(2 * UInt(cassignments.count), context.getPendingCount()) // overriding with the same variant shouldn't clear assignment cache - cassignments.forEach { - context.setCustomAssignment(experimentName: $0.key, variant: $0.value) - XCTAssertEqual($0.value, context.getTreatment($0.key)) + for (key, value) in cassignments { + context.setCustomAssignment(experimentName: key, variant: value) + XCTAssertEqual(value, context.getTreatment(key)) } XCTAssertEqual(2 * UInt(cassignments.count), context.getPendingCount()) // overriding with the different variant should clear assignment cache - cassignments.forEach { - context.setCustomAssignment(experimentName: $0.key, variant: $0.value + 11) - XCTAssertEqual($0.value + 11, context.getTreatment($0.key)) + for (key, value) in cassignments { + context.setCustomAssignment(experimentName: key, variant: value + 11) + XCTAssertEqual(value + 11, context.getTreatment(key)) } XCTAssertEqual(3 * UInt(cassignments.count), context.getPendingCount()) @@ -576,8 +610,8 @@ final class ContextTest: XCTestCase { let contextData = try getContextData() let context = try createContext(config: contextConfig, data: Promise.value(contextData)) - contextData.experiments.forEach { - XCTAssertEqual(expectedVariants[$0.name], context.peekTreatment($0.name)) + for element in contextData.experiments { + XCTAssertEqual(expectedVariants[element.name], context.peekTreatment(element.name)) } XCTAssertEqual(0, context.peekTreatment("no_found")) @@ -589,7 +623,7 @@ final class ContextTest: XCTestCase { let contextData = try getContextData() let context = try createContext(config: contextConfig, data: Promise.value(contextData)) - variableExperiments.forEach { variableName, experimentNames in + for (variableName, experimentNames) in variableExperiments { let actual = context.peekVariableValue(variableName, defaultValue: 17) let eligible = experimentNames[0] != "exp_test_not_eligible" @@ -654,7 +688,7 @@ final class ContextTest: XCTestCase { let contextData = try getContextData() let context = try createContext(config: contextConfig, data: Promise.value(contextData)) - variableExperiments.forEach { variableName, experimentNames in + for (variableName, experimentNames) in variableExperiments { let actual = context.getVariableValue(variableName, defaultValue: 17) let eligible = experimentNames[0] != "exp_test_not_eligible" @@ -829,17 +863,17 @@ final class ContextTest: XCTestCase { context.setOverrides(expectedVariants.mapValues { 11 + $0 }) context.setOverride(experimentName: "not_found", variant: 3) - contextData.experiments.forEach { - if let variant = expectedVariants[$0.name] { - XCTAssertEqual(variant + 11, context.peekTreatment($0.name)) + for element in contextData.experiments { + if let variant = expectedVariants[element.name] { + XCTAssertEqual(variant + 11, context.peekTreatment(element.name)) } } XCTAssertEqual(3, context.peekTreatment("not_found")) // call again - contextData.experiments.forEach { - if let variant = expectedVariants[$0.name] { - XCTAssertEqual(variant + 11, context.peekTreatment($0.name)) + for element in contextData.experiments { + if let variant = expectedVariants[element.name] { + XCTAssertEqual(variant + 11, context.peekTreatment(element.name)) } } XCTAssertEqual(3, context.peekTreatment("not_found")) @@ -867,9 +901,9 @@ final class ContextTest: XCTestCase { let contextData = try getContextData() let context = try createContext(config: contextConfig, data: Promise.value(contextData)) - contextData.experiments.forEach { - if let variant = expectedVariants[$0.name] { - XCTAssertEqual(variant, context.getTreatment($0.name)) + for element in contextData.experiments { + if let variant = expectedVariants[element.name] { + XCTAssertEqual(variant, context.getTreatment(element.name)) } } XCTAssertEqual(0, context.getTreatment("not_found")) @@ -939,9 +973,9 @@ final class ContextTest: XCTestCase { context.setOverrides(expectedVariants.mapValues { 11 + $0 }) context.setOverride(experimentName: "not_found", variant: 3) - contextData.experiments.forEach { - if let variant = expectedVariants[$0.name] { - XCTAssertEqual(variant + 11, context.getTreatment($0.name)) + for element in contextData.experiments { + if let variant = expectedVariants[element.name] { + XCTAssertEqual(variant + 11, context.getTreatment(element.name)) } } XCTAssertEqual(3, context.getTreatment("not_found")) @@ -985,18 +1019,18 @@ final class ContextTest: XCTestCase { let contextData = try getContextData() let context = try createContext(config: contextConfig, data: Promise.value(contextData)) - contextData.experiments.forEach { - if let variant = expectedVariants[$0.name] { - XCTAssertEqual(variant, context.getTreatment($0.name)) + for element in contextData.experiments { + if let variant = expectedVariants[element.name] { + XCTAssertEqual(variant, context.getTreatment(element.name)) } } XCTAssertEqual(0, context.getTreatment("not_found")) XCTAssertEqual(1 + UInt(contextData.experiments.count), context.getPendingCount()) // call again - contextData.experiments.forEach { - if let variant = expectedVariants[$0.name] { - XCTAssertEqual(variant, context.getTreatment($0.name)) + for element in contextData.experiments { + if let variant = expectedVariants[element.name] { + XCTAssertEqual(variant, context.getTreatment(element.name)) } } XCTAssertEqual(0, context.getTreatment("not_found")) @@ -1345,7 +1379,7 @@ final class ContextTest: XCTestCase { XCTAssertEqual(1, logger.handleEventContextEventCallsCount) XCTAssertTrue(context === logger.handleEventContextEventReceivedArguments!.context) XCTAssertEqual( - ContextEventLoggerEvent.publish(event: expected), logger.handleEventContextEventReceivedArguments!.event + try ContextEventLoggerEvent.publish(event: expected), logger.handleEventContextEventReceivedArguments!.event ) expectation.fulfill() @@ -1725,7 +1759,7 @@ final class ContextTest: XCTestCase { XCTAssertEqual(1, logger.handleEventContextEventCallsCount) XCTAssertTrue(context === logger.handleEventContextEventReceivedArguments!.context) XCTAssertEqual( - ContextEventLoggerEvent.refresh(data: refreshedContextData), + try ContextEventLoggerEvent.refresh(data: refreshedContextData), logger.handleEventContextEventReceivedArguments!.event) expectation.fulfill() @@ -1790,7 +1824,7 @@ final class ContextTest: XCTestCase { let context = try createContext(config: contextConfig, data: Promise.value(contextData)) XCTAssertTrue(context.isReady()) - contextData.experiments.forEach { _ = context.getTreatment($0.name) } + for exp in contextData.experiments { _ = context.getTreatment(exp.name) } _ = context.getTreatment("not_found") XCTAssertEqual(1 + UInt(contextData.experiments.count), context.getPendingCount()) @@ -1812,10 +1846,10 @@ final class ContextTest: XCTestCase { wait(for: [expectation], timeout: 1.0) - contextData.experiments.forEach { _ = context.getTreatment($0.name) } + for exp in contextData.experiments { _ = context.getTreatment(exp.name) } _ = context.getTreatment("not_found") - XCTAssertEqual(1 + UInt(contextData.experiments.count), context.getPendingCount()) + XCTAssertEqual(1 + UInt(contextData.experiments.count) + 1, context.getPendingCount()) } func testRefreshKeepsAssignmentCacheWhenNotChangedOnAudienceMismatch() throws { @@ -1907,7 +1941,7 @@ final class ContextTest: XCTestCase { XCTAssertEqual(0, context.getTreatment(experimentName)) XCTAssertEqual(0, context.getTreatment("not_found")) - XCTAssertEqual(3, context.getPendingCount()) // stopped experiment triggered a new exposure + XCTAssertEqual(4, context.getPendingCount()) // refresh resets exposure state for all assignments } func testRefreshClearsAssignmentCacheForStartedExperiment() throws { @@ -1942,7 +1976,7 @@ final class ContextTest: XCTestCase { XCTAssertEqual(1, context.getTreatment(experimentName)) XCTAssertEqual(0, context.getTreatment("not_found")) - XCTAssertEqual(3, context.getPendingCount()) // started experiment triggered a new exposure + XCTAssertEqual(4, context.getPendingCount()) // refresh resets exposure state for all assignments } func testRefreshClearsAssignmentCacheForFullOnExperiment() throws { @@ -1977,7 +2011,7 @@ final class ContextTest: XCTestCase { XCTAssertEqual(1, context.getTreatment(experimentName)) XCTAssertEqual(0, context.getTreatment("not_found")) - XCTAssertEqual(3, context.getPendingCount()) // full-on experiment triggered a new exposure + XCTAssertEqual(4, context.getPendingCount()) // refresh resets exposure state for all assignments } func testRefreshClearsAssignmentCacheForTrafficSplitChange() throws { @@ -2012,7 +2046,7 @@ final class ContextTest: XCTestCase { XCTAssertEqual(2, context.getTreatment(experimentName)) XCTAssertEqual(0, context.getTreatment("not_found")) - XCTAssertEqual(3, context.getPendingCount()) // newly eligible experiment triggered a new exposure + XCTAssertEqual(4, context.getPendingCount()) // refresh resets exposure state for all assignments } func testRefreshClearsAssignmentCacheForExperimentIdChange() throws { @@ -2047,7 +2081,42 @@ final class ContextTest: XCTestCase { XCTAssertEqual(2, context.getTreatment(experimentName)) XCTAssertEqual(0, context.getTreatment("not_found")) - XCTAssertEqual(3, context.getPendingCount()) // newly eligible experiment triggered a new exposure + XCTAssertEqual(4, context.getPendingCount()) // refresh resets exposure state for all assignments + } + + func testRefreshClearsAssignmentCacheForIterationChange() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let contextData = try getContextData() + let context = try createContext(config: contextConfig, data: Promise.value(contextData)) + XCTAssertTrue(context.isReady()) + + let experimentName = "exp_test_abc" + XCTAssertEqual(2, context.getTreatment(experimentName)) + XCTAssertEqual(0, context.getTreatment("not_found")) + + XCTAssertEqual(2, context.getPendingCount()) + + let refreshedContextData = try getContextData(source: "refreshed_iteration") + let (promise, resolver) = Promise.pending() + provider.getContextDataReturnValue = promise + + let expectation = XCTestExpectation() + + _ = context.refresh().done { [self] in + XCTAssertEqual(1, provider.getContextDataCallsCount) + XCTAssertEqual(refreshedContextData.experiments.map { $0.name }, context.getExperiments()) + + expectation.fulfill() + } + + resolver.fulfill(refreshedContextData) + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(2, context.getTreatment(experimentName)) + XCTAssertEqual(0, context.getTreatment("not_found")) + + XCTAssertEqual(4, context.getPendingCount()) } func testGetCustomFieldKeys() throws { @@ -2068,8 +2137,15 @@ final class ContextTest: XCTestCase { XCTAssertEqual("US,PT,ES,DE,FR", context.getCustomFieldValue(experimentName: "exp_test_ab", key: "country") as! String) XCTAssertEqual("string", context.getCustomFieldValueType(experimentName: "exp_test_ab", key: "country") as! String) - let data: [String: JSON] = ["123": 1, "456": 0] - XCTAssertEqual(data, context.getCustomFieldValue(experimentName: "exp_test_ab", key: "overrides") as! [String: JSON]); + let overridesValue = context.getCustomFieldValue(experimentName: "exp_test_ab", key: "overrides") + if let overridesDict = overridesValue as? [String: JSON] { + XCTAssertEqual(["123": JSON(1), "456": JSON(0)], overridesDict) + } else if let overridesDict = overridesValue as? [String: Any] { + XCTAssertEqual(1, overridesDict["123"] as? Int ?? (overridesDict["123"] as? Bool == true ? 1 : 0)) + XCTAssertEqual(0, overridesDict["456"] as? Int ?? (overridesDict["456"] as? Bool == true ? 1 : 0)) + } else { + XCTFail("Unexpected overrides type: \(String(describing: overridesValue))") + } XCTAssertEqual("json", context.getCustomFieldValueType(experimentName: "exp_test_ab", key: "overrides") as! String); XCTAssertNil(context.getCustomFieldValue(experimentName: "exp_test_ab", key: "languages")); @@ -2089,4 +2165,510 @@ final class ContextTest: XCTestCase { XCTAssertNil(context.getCustomFieldValue(experimentName: "exp_test_no_custom_fields", key: "languages")); XCTAssertNil(context.getCustomFieldValueType(experimentName: "exp_test_no_custom_fields", key: "languages")); } + + func testRecoveryAfterPublishFailure() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + context.track("goal1", properties: ["amount": 125]) + + XCTAssertEqual(1, context.getPendingCount()) + + let failExpectation = XCTestExpectation(description: "Publish fails") + + let (failPromise, failResolver) = Promise.pending() + handler.publishEventReturnValue = failPromise + + _ = context.publish().catch { error in + XCTAssertTrue(error is ABSmartlyError) + failExpectation.fulfill() + } + + failResolver.reject(ABSmartlyError("test publish failure")) + + wait(for: [failExpectation], timeout: 1.0) + + XCTAssertTrue(context.isReady()) + XCTAssertFalse(context.isClosed()) + XCTAssertFalse(context.isFailed()) + + context.track("goal2", properties: ["value": 200]) + XCTAssertEqual(2, context.getPendingCount()) + + let treatment = context.getTreatment("exp_test_ab") + XCTAssertEqual(1, treatment) + XCTAssertEqual(3, context.getPendingCount()) + + let successExpectation = XCTestExpectation(description: "Publish succeeds") + + handler.publishEventReturnValue = Promise.value(()) + + _ = context.publish().done { + successExpectation.fulfill() + } + + wait(for: [successExpectation], timeout: 1.0) + + XCTAssertEqual(0, context.getPendingCount()) + } + + func testRecoveryAfterRefreshFailure() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + XCTAssertTrue(context.isReady()) + + let failExpectation = XCTestExpectation(description: "Refresh fails") + + let (failPromise, failResolver) = Promise.pending() + provider.getContextDataReturnValue = failPromise + + _ = context.refresh().catch { error in + XCTAssertTrue(error is ABSmartlyError) + failExpectation.fulfill() + } + + failResolver.reject(ABSmartlyError("test refresh failure")) + + wait(for: [failExpectation], timeout: 1.0) + + XCTAssertTrue(context.isReady()) + XCTAssertFalse(context.isFailed()) + XCTAssertFalse(context.isClosed()) + + let treatment = context.getTreatment("exp_test_ab") + XCTAssertEqual(1, treatment) + + context.track("goal_after_refresh_failure", properties: nil) + XCTAssertEqual(2, context.getPendingCount()) + + let successExpectation = XCTestExpectation(description: "Refresh succeeds") + + let refreshedContextData = try getContextData(source: "refreshed") + provider.getContextDataReturnValue = Promise.value(refreshedContextData) + + _ = context.refresh().done { + successExpectation.fulfill() + } + + wait(for: [successExpectation], timeout: 1.0) + + XCTAssertTrue(context.isReady()) + XCTAssertEqual(refreshedContextData.experiments.map { $0.name }, context.getExperiments()) + } + + func testGracefulDegradationNoNetwork() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let (promise, resolver) = Promise.pending() + let context = try createContext(config: contextConfig, data: promise) + XCTAssertFalse(context.isReady()) + + let expectation = XCTestExpectation(description: "Context handles network failure") + + resolver.reject(ABSmartlyError("Network connection failed")) + + _ = context.waitUntilReady().done { ctx in + XCTAssertTrue(ctx.isReady()) + XCTAssertTrue(ctx.isFailed()) + + let treatment = ctx.getTreatment("exp_test_ab") + XCTAssertEqual(0, treatment) + + ctx.track("goal_offline", properties: nil) + XCTAssertEqual(2, ctx.getPendingCount()) + + ctx.setOverride(experimentName: "exp_test_ab", variant: 5) + XCTAssertEqual(5, ctx.getTreatment("exp_test_ab")) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + let publishExpectation = XCTestExpectation(description: "Publish completes without calling handler") + + _ = context.publish().done { [self] in + XCTAssertEqual(0, handler.publishEventCallsCount) + publishExpectation.fulfill() + } + + wait(for: [publishExpectation], timeout: 1.0) + } + + func testRetryMechanismActivation() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + context.track("goal1", properties: nil) + + var publishAttempts = 0 + let maxAttempts = 3 + + let expectation = XCTestExpectation(description: "Retry mechanism test") + + handler.publishEventClosure = { _ in + publishAttempts += 1 + if publishAttempts < maxAttempts { + return Promise(error: ABSmartlyError("Transient failure \(publishAttempts)")) + } else { + return Promise.value(()) + } + } + + _ = context.publish().done { + expectation.fulfill() + }.catch { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + + XCTAssertGreaterThanOrEqual(publishAttempts, 1) + } + + func testFailedToReadyTransition() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext( + config: contextConfig, data: Promise.init(error: ABSmartlyError("initial failure"))) + + XCTAssertTrue(context.isReady()) + XCTAssertTrue(context.isFailed()) + + let treatment = context.getTreatment("exp_test_ab") + XCTAssertEqual(0, treatment) + + context.track("goal_while_failed", properties: nil) + XCTAssertEqual(2, context.getPendingCount()) + + XCTAssertEqual(0, handler.publishEventCallsCount) + } + + func testRapidCloseReopenCycle() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + context.track("goal1", properties: nil) + + handler.publishEventReturnValue = Promise.value(()) + + let closeExpectation = XCTestExpectation(description: "Close completes") + + _ = context.close().done { + closeExpectation.fulfill() + } + + wait(for: [closeExpectation], timeout: 1.0) + + XCTAssertTrue(context.isClosed()) + + let context2 = try createContext(config: contextConfig) + XCTAssertTrue(context2.isReady()) + XCTAssertFalse(context2.isClosed()) + + let treatment = context2.getTreatment("exp_test_ab") + XCTAssertEqual(1, treatment) + } + + func testAllStateTransitionPaths() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let (promise, resolver) = Promise.pending() + let context = try createContext(config: contextConfig, data: promise) + + XCTAssertFalse(context.isReady()) + XCTAssertFalse(context.isFailed()) + XCTAssertFalse(context.isClosing()) + XCTAssertFalse(context.isClosed()) + + let readyExpectation = XCTestExpectation(description: "Ready state reached") + + resolver.fulfill(try getContextData()) + + _ = context.waitUntilReady().done { ctx in + XCTAssertTrue(ctx.isReady()) + XCTAssertFalse(ctx.isFailed()) + XCTAssertFalse(ctx.isClosing()) + XCTAssertFalse(ctx.isClosed()) + readyExpectation.fulfill() + } + + wait(for: [readyExpectation], timeout: 1.0) + + context.track("goal1", properties: nil) + + let (publishPromise, publishResolver) = Promise.pending() + handler.publishEventReturnValue = publishPromise + + let closePromise = context.close() + + XCTAssertTrue(context.isClosing()) + XCTAssertFalse(context.isClosed()) + + let closeExpectation = XCTestExpectation(description: "Close state reached") + + _ = closePromise.done { + closeExpectation.fulfill() + } + + publishResolver.fulfill(()) + + wait(for: [closeExpectation], timeout: 1.0) + + XCTAssertTrue(context.isClosed()) + XCTAssertFalse(context.isClosing()) + } + + func testCustomFieldValueAllTypes() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let contextData = try getContextData() + let context = try createContext(config: contextConfig, data: Promise.value(contextData)) + XCTAssertTrue(context.isReady()) + + let stringValue = context.getCustomFieldValue(experimentName: "exp_test_ab", key: "country") + XCTAssertNotNil(stringValue) + XCTAssertTrue(stringValue is String) + XCTAssertEqual("string", context.getCustomFieldValueType(experimentName: "exp_test_ab", key: "country")) + + let jsonValue = context.getCustomFieldValue(experimentName: "exp_test_ab", key: "overrides") + XCTAssertNotNil(jsonValue) + XCTAssertEqual("json", context.getCustomFieldValueType(experimentName: "exp_test_ab", key: "overrides")) + } + + func testCustomFieldNullHandling() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let contextData = try getContextData() + let context = try createContext(config: contextConfig, data: Promise.value(contextData)) + XCTAssertTrue(context.isReady()) + + let missingExperimentValue = context.getCustomFieldValue(experimentName: "non_existent_experiment", key: "any_key") + XCTAssertNil(missingExperimentValue) + XCTAssertNil(context.getCustomFieldValueType(experimentName: "non_existent_experiment", key: "any_key")) + + let missingKeyValue = context.getCustomFieldValue(experimentName: "exp_test_ab", key: "non_existent_key") + XCTAssertNil(missingKeyValue) + XCTAssertNil(context.getCustomFieldValueType(experimentName: "exp_test_ab", key: "non_existent_key")) + + let existingValue = context.getCustomFieldValue(experimentName: "exp_test_ab", key: "languages") + XCTAssertNil(existingValue) + } + + func testCustomFieldTypeCoercion() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let contextData = try getContextData() + let context = try createContext(config: contextConfig, data: Promise.value(contextData)) + XCTAssertTrue(context.isReady()) + + let keys = context.getCustomFieldKeys() + XCTAssertTrue(keys.contains("country")) + XCTAssertTrue(keys.contains("languages")) + XCTAssertTrue(keys.contains("overrides")) + + let experimentKeys = context.getCustomFieldKeys(experimentName: "exp_test_ab") + XCTAssertTrue(experimentKeys.contains("country")) + XCTAssertTrue(experimentKeys.contains("overrides")) + } + + // MARK: - Fix #1: setData visibility is internal + + func testSetDataIsNotPublic() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + XCTAssertTrue(context.isReady()) + let data = try getContextData() + context.setData(data) + XCTAssertNotNil(context.getContextData()) + } + + // MARK: - Fix #2: Flush events restored on publish failure + + func testFlushRestoresEventsOnPublishFailure() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + context.track("goal1", properties: ["amount": 125]) + context.track("goal2", properties: ["value": 200]) + + XCTAssertEqual(2, context.getPendingCount()) + + let failExpectation = XCTestExpectation(description: "Publish fails") + + let (failPromise, failResolver) = Promise.pending() + handler.publishEventReturnValue = failPromise + + _ = context.publish().catch { error in + XCTAssertTrue(error is ABSmartlyError) + failExpectation.fulfill() + } + + failResolver.reject(ABSmartlyError("publish failure")) + + wait(for: [failExpectation], timeout: 1.0) + + XCTAssertEqual(2, context.getPendingCount()) + + let successExpectation = XCTestExpectation(description: "Publish succeeds with restored events") + + handler.publishEventReturnValue = Promise.value(()) + + _ = context.publish().done { [self] in + XCTAssertEqual(2, handler.publishEventCallsCount) + let event = handler.publishEventReceivedInvocations.last! + XCTAssertEqual(2, event.goals.count) + successExpectation.fulfill() + } + + wait(for: [successExpectation], timeout: 1.0) + + XCTAssertEqual(0, context.getPendingCount()) + } + + func testFlushRestoresExposuresOnPublishFailure() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + _ = context.getTreatment("exp_test_ab") + XCTAssertEqual(1, context.getPendingCount()) + + let failExpectation = XCTestExpectation(description: "Publish fails") + + let (failPromise, failResolver) = Promise.pending() + handler.publishEventReturnValue = failPromise + + _ = context.publish().catch { _ in + failExpectation.fulfill() + } + + failResolver.reject(ABSmartlyError("publish failure")) + + wait(for: [failExpectation], timeout: 1.0) + + XCTAssertEqual(1, context.getPendingCount()) + } + + // MARK: - Fix #6: Refresh only resets exposure for changed experiments + + func testRefreshDoesNotResetExposureForUnchangedExperiments() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let contextData = try getContextData() + let context = try createContext(config: contextConfig, data: Promise.value(contextData)) + + _ = context.getTreatment("exp_test_ab") + XCTAssertEqual(1, context.getPendingCount()) + + let (promise, resolver) = Promise.pending() + provider.getContextDataReturnValue = promise + + let expectation = XCTestExpectation() + + _ = context.refresh().done { + _ = context.getTreatment("exp_test_ab") + XCTAssertEqual(1, context.getPendingCount()) + expectation.fulfill() + } + + resolver.fulfill(contextData) + + wait(for: [expectation], timeout: 1.0) + } + + func testRefreshResetsExposureForChangedExperiments() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let contextData = try getContextData() + let context = try createContext(config: contextConfig, data: Promise.value(contextData)) + + _ = context.getTreatment("exp_test_abc") + XCTAssertEqual(1, context.getPendingCount()) + + let refreshedContextData = try getContextData(source: "refreshed_iteration") + let (promise, resolver) = Promise.pending() + provider.getContextDataReturnValue = promise + + let expectation = XCTestExpectation() + + _ = context.refresh().done { + _ = context.getTreatment("exp_test_abc") + XCTAssertEqual(2, context.getPendingCount()) + expectation.fulfill() + } + + resolver.fulfill(refreshedContextData) + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Fix #14/16: contextLock in flush uses defer + + func testFlushContextLockUsesDefer() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + context.track("goal1", properties: nil) + XCTAssertEqual(1, context.getPendingCount()) + + handler.publishEventReturnValue = Promise.value(()) + + let expectation = XCTestExpectation() + + _ = context.publish().done { + context.track("goal2", properties: nil) + XCTAssertEqual(1, context.getPendingCount()) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Fix #17: refreshPromise/closePromise protected by promiseLock + + func testConcurrentRefreshReturnsSamePromise() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + let (promise, resolver) = Promise.pending() + provider.getContextDataReturnValue = promise + + let refreshPromise1 = context.refresh() + let refreshPromise2 = context.refresh() + + XCTAssertEqual(1, provider.getContextDataCallsCount) + + let expectation = XCTestExpectation() + expectation.expectedFulfillmentCount = 2 + + _ = refreshPromise1.done { expectation.fulfill() } + _ = refreshPromise2.done { expectation.fulfill() } + + resolver.fulfill(try getContextData()) + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Fix #18: setTimeout race condition removed + + func testSetTimeoutNoRaceCondition() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + _ = context.getTreatment("exp_test_ab") + XCTAssertTrue(scheduler.scheduleAfterExecuteCalled) + + context.track("goal1", properties: nil) + XCTAssertEqual(1, scheduler.scheduleAfterExecuteCallsCount) + } + + // MARK: - Fix 4.1: setOverride succeeds after close + + func testSetOverrideSucceedsAfterClose() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + handler.publishEventReturnValue = Promise.value(()) + + let expectation = XCTestExpectation() + _ = context.close().done { + context.setOverride(experimentName: "exp_test", variant: 2) + XCTAssertEqual(2, context.getOverride(experimentName: "exp_test")) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } } diff --git a/Tests/ABSmartlyTests/DefaultClientTest.swift b/Tests/ABSmartlyTests/DefaultClientTest.swift index 59f67a2..de102f1 100644 --- a/Tests/ABSmartlyTests/DefaultClientTest.swift +++ b/Tests/ABSmartlyTests/DefaultClientTest.swift @@ -19,6 +19,38 @@ final class DefaultClientTest: XCTestCase { } } + func testThrowsWithMissingEndpoint() { + let clientConfig = ClientConfig( + apiKey: "test", application: "test_app", endpoint: "", environment: "test") + XCTAssertThrowsError(try DefaultClient(config: clientConfig, httpClient: HTTPClientMock())) { error in + XCTAssertEqual(error.localizedDescription, "Missing Endpoint configuration") + } + } + + func testThrowsWithMissingApiKey() { + let clientConfig = ClientConfig( + apiKey: "", application: "test_app", endpoint: "https://test.absmartly.io/v1", environment: "test") + XCTAssertThrowsError(try DefaultClient(config: clientConfig, httpClient: HTTPClientMock())) { error in + XCTAssertEqual(error.localizedDescription, "Missing APIKey configuration") + } + } + + func testThrowsWithMissingApplication() { + let clientConfig = ClientConfig( + apiKey: "test", application: "", endpoint: "https://test.absmartly.io/v1", environment: "test") + XCTAssertThrowsError(try DefaultClient(config: clientConfig, httpClient: HTTPClientMock())) { error in + XCTAssertEqual(error.localizedDescription, "Missing Application configuration") + } + } + + func testThrowsWithMissingEnvironment() { + let clientConfig = ClientConfig( + apiKey: "test", application: "test_app", endpoint: "https://test.absmartly.io/v1", environment: "") + XCTAssertThrowsError(try DefaultClient(config: clientConfig, httpClient: HTTPClientMock())) { error in + XCTAssertEqual(error.localizedDescription, "Missing Environment configuration") + } + } + func testGetContextData() { guard let client = client, let httpClient = httpClient else { return } @@ -41,7 +73,7 @@ final class DefaultClientTest: XCTestCase { XCTFail(error.localizedDescription) } - let path = Bundle.module.path(forResource: "context", ofType: "json", inDirectory: "Resources")! + let path = TestResources.path(forResource: "context", ofType: "json") do { let data = try Foundation.Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) let response = DefaultHTTPResponse( @@ -52,6 +84,328 @@ final class DefaultClientTest: XCTestCase { } } + func testGetContextDataRejectsOnHttpError() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.getUrlQueryHeadersReturnValue = promise + + let expectation = XCTestExpectation() + + let result = client.getContextData() + + result.done { _ in + XCTFail("Expected rejection") + }.catch { error in + XCTAssertTrue(error is ABSmartlyHTTPError) + let httpError = error as! ABSmartlyHTTPError + XCTAssertEqual(500, httpError.statusCode) + expectation.fulfill() + } + + let response = DefaultHTTPResponse( + status: 500, statusMessage: "Internal Server Error", contentType: "text/plain", content: Data()) + resolver.fulfill(response) + + wait(for: [expectation], timeout: 1.0) + } + + func testGetContextDataRejectsOnNetworkError() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.getUrlQueryHeadersReturnValue = promise + + let expectation = XCTestExpectation() + + let result = client.getContextData() + + result.done { _ in + XCTFail("Expected rejection") + }.catch { error in + XCTAssertTrue(error is ABSmartlyError) + expectation.fulfill() + } + + resolver.reject(ABSmartlyError("Connection refused")) + + wait(for: [expectation], timeout: 1.0) + } + + func testGetContextDataRejectsOnMalformedResponse() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.getUrlQueryHeadersReturnValue = promise + + let expectation = XCTestExpectation() + + let result = client.getContextData() + + result.done { _ in + XCTFail("Expected rejection") + }.catch { error in + XCTAssertTrue(error is DecodingError) + expectation.fulfill() + } + + let malformedData = "not valid json".data(using: .utf8)! + let response = DefaultHTTPResponse( + status: 200, statusMessage: "OK", contentType: "application/json", content: malformedData) + resolver.fulfill(response) + + wait(for: [expectation], timeout: 1.0) + } + + func testPublishCallsEndpoint() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.putUrlQueryHeadersBodyReturnValue = promise + + let expectation = XCTestExpectation() + + let event = PublishEvent() + event.hashed = true + event.units = [Unit(type: "session_id", uid: "abc123")] + event.publishedAt = 1_620_000_000_000 + + let result = client.publish(event: event) + + result.done { + expectation.fulfill() + }.catch { error in + XCTFail(error.localizedDescription) + } + + XCTAssertEqual(1, httpClient.putUrlQueryHeadersBodyCallsCount) + XCTAssertEqual("https://test.absmartly.io/v1/context", httpClient.putUrlQueryHeadersBodyReceivedArguments?.url) + XCTAssertNil(httpClient.putUrlQueryHeadersBodyReceivedArguments?.query) + + let headers = httpClient.putUrlQueryHeadersBodyReceivedArguments?.headers + XCTAssertNotNil(headers) + XCTAssertEqual("application/json; charset=utf-8", headers?["Content-Type"]) + XCTAssertEqual("test", headers?["X-API-Key"]) + XCTAssertEqual("test", headers?["X-Environment"]) + XCTAssertEqual("test_app", headers?["X-Application"]) + XCTAssertEqual("absmartly-swift-sdk", headers?["X-Agent"]) + + XCTAssertNotNil(httpClient.putUrlQueryHeadersBodyReceivedArguments?.body) + + let response = DefaultHTTPResponse( + status: 200, statusMessage: "OK", contentType: "application/json", content: Data()) + resolver.fulfill(response) + + wait(for: [expectation], timeout: 1.0) + } + + func testPublishSendsCorrectBody() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.putUrlQueryHeadersBodyReturnValue = promise + + let expectation = XCTestExpectation() + + let event = PublishEvent() + event.hashed = true + event.units = [Unit(type: "session_id", uid: "abc123")] + event.publishedAt = 1_620_000_000_000 + event.exposures = [ + Exposure(1, "exp_test", "session_id", 1, 1_620_000_000_000, true, true, false, false, false, false) + ] + event.goals = [ + GoalAchievement("goal1", achievedAt: 1_620_000_000_000, properties: ["amount": 100]) + ] + + let result = client.publish(event: event) + + result.done { + expectation.fulfill() + }.catch { error in + XCTFail(error.localizedDescription) + } + + let body = httpClient.putUrlQueryHeadersBodyReceivedArguments?.body + XCTAssertNotNil(body) + + if let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] { + XCTAssertEqual(true, json["hashed"] as? Bool) + XCTAssertEqual(1_620_000_000_000, json["publishedAt"] as? Int64) + XCTAssertNotNil(json["units"]) + XCTAssertNotNil(json["exposures"]) + XCTAssertNotNil(json["goals"]) + } + + let response = DefaultHTTPResponse( + status: 200, statusMessage: "OK", contentType: "application/json", content: Data()) + resolver.fulfill(response) + + wait(for: [expectation], timeout: 1.0) + } + + func testPublishOmitsEmptyArrays() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.putUrlQueryHeadersBodyReturnValue = promise + + let expectation = XCTestExpectation() + + let event = PublishEvent() + event.hashed = true + event.units = [Unit(type: "session_id", uid: "abc123")] + event.publishedAt = 1_620_000_000_000 + + let result = client.publish(event: event) + + result.done { + expectation.fulfill() + }.catch { error in + XCTFail(error.localizedDescription) + } + + let body = httpClient.putUrlQueryHeadersBodyReceivedArguments?.body + XCTAssertNotNil(body) + + if let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] { + XCTAssertNil(json["exposures"]) + XCTAssertNil(json["goals"]) + XCTAssertNil(json["attributes"]) + } + + let response = DefaultHTTPResponse( + status: 200, statusMessage: "OK", contentType: "application/json", content: Data()) + resolver.fulfill(response) + + wait(for: [expectation], timeout: 1.0) + } + + func testPublishRejectsOnHttpError() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.putUrlQueryHeadersBodyReturnValue = promise + + let expectation = XCTestExpectation() + + let event = PublishEvent() + event.hashed = true + event.units = [Unit(type: "session_id", uid: "abc123")] + event.publishedAt = 1_620_000_000_000 + + let result = client.publish(event: event) + + result.done { + XCTFail("Expected rejection") + }.catch { error in + XCTAssertTrue(error is ABSmartlyHTTPError) + let httpError = error as! ABSmartlyHTTPError + XCTAssertEqual(500, httpError.statusCode) + expectation.fulfill() + } + + let response = DefaultHTTPResponse( + status: 500, statusMessage: "Internal Server Error", contentType: "text/plain", content: Data()) + resolver.fulfill(response) + + wait(for: [expectation], timeout: 1.0) + } + + func testPublishRejectsOnNetworkError() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.putUrlQueryHeadersBodyReturnValue = promise + + let expectation = XCTestExpectation() + + let event = PublishEvent() + event.hashed = true + event.units = [Unit(type: "session_id", uid: "abc123")] + event.publishedAt = 1_620_000_000_000 + + let result = client.publish(event: event) + + result.done { + XCTFail("Expected rejection") + }.catch { error in + XCTAssertTrue(error is ABSmartlyError) + expectation.fulfill() + } + + resolver.reject(ABSmartlyError("Connection refused")) + + wait(for: [expectation], timeout: 1.0) + } + + func testPublishRejectsOnClientError() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.putUrlQueryHeadersBodyReturnValue = promise + + let expectation = XCTestExpectation() + + let event = PublishEvent() + event.hashed = true + event.units = [Unit(type: "session_id", uid: "abc123")] + event.publishedAt = 1_620_000_000_000 + + let result = client.publish(event: event) + + result.done { + XCTFail("Expected rejection") + }.catch { error in + XCTAssertTrue(error is ABSmartlyHTTPError) + let httpError = error as! ABSmartlyHTTPError + XCTAssertEqual(400, httpError.statusCode) + expectation.fulfill() + } + + let response = DefaultHTTPResponse( + status: 400, statusMessage: "Bad Request", contentType: "text/plain", content: Data()) + resolver.fulfill(response) + + wait(for: [expectation], timeout: 1.0) + } + + func testPublishSetsApplicationVersionHeader() throws { + let httpMock = HTTPClientMock() + let clientConfig = ClientConfig( + apiKey: "test", application: "test_app", endpoint: "https://test.absmartly.io/v1", + environment: "test", + applicationVersion: "1.2.3") + let versionClient = try DefaultClient(config: clientConfig, httpClient: httpMock) + + let (promise, resolver) = Promise.pending() + httpMock.putUrlQueryHeadersBodyReturnValue = promise + + let expectation = XCTestExpectation() + + let event = PublishEvent() + event.hashed = true + event.units = [Unit(type: "session_id", uid: "abc123")] + event.publishedAt = 1_620_000_000_000 + + let result = versionClient.publish(event: event) + + result.done { + expectation.fulfill() + }.catch { error in + XCTFail(error.localizedDescription) + } + + let headers = httpMock.putUrlQueryHeadersBodyReceivedArguments?.headers + XCTAssertEqual("1.2.3", headers?["X-Application-Version"]) + + let response = DefaultHTTPResponse( + status: 200, statusMessage: "OK", contentType: "application/json", content: Data()) + resolver.fulfill(response) + + wait(for: [expectation], timeout: 1.0) + } + func testClose() { guard let client = client, let httpClient = httpClient else { return } @@ -71,4 +425,133 @@ final class DefaultClientTest: XCTestCase { XCTAssertEqual(1, httpClient.closeCallsCount) resolver.fulfill(()) } + + func testGetContextDataSetsCorrectQueryParameters() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, _) = Promise.pending() + httpClient.getUrlQueryHeadersReturnValue = promise + + _ = client.getContextData() + + let query = httpClient.getUrlQueryHeadersReceivedArguments?.query + XCTAssertEqual("test_app", query?["application"]) + XCTAssertEqual("test", query?["environment"]) + XCTAssertEqual(2, query?.count) + } + + func testGetContextDataDoesNotSendHeaders() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, _) = Promise.pending() + httpClient.getUrlQueryHeadersReturnValue = promise + + _ = client.getContextData() + + XCTAssertNil(httpClient.getUrlQueryHeadersReceivedArguments?.headers) + } + + func testConstructorAcceptsValidConfig() throws { + let clientConfig = ClientConfig( + apiKey: "my-key", application: "my-app", endpoint: "https://example.com/v1", environment: "production") + let validClient = try DefaultClient(config: clientConfig, httpClient: HTTPClientMock()) + XCTAssertNotNil(validClient) + } + + func testMultipleGetContextDataCalls() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise1, _) = Promise.pending() + let (promise2, _) = Promise.pending() + + httpClient.getUrlQueryHeadersReturnValue = promise1 + _ = client.getContextData() + + httpClient.getUrlQueryHeadersReturnValue = promise2 + _ = client.getContextData() + + XCTAssertEqual(2, httpClient.getUrlQueryHeadersCallsCount) + } + + func testMultiplePublishCalls() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise1, _) = Promise.pending() + let (promise2, _) = Promise.pending() + + let event1 = PublishEvent() + event1.hashed = true + event1.units = [Unit(type: "session_id", uid: "abc123")] + event1.publishedAt = 1_620_000_000_000 + + let event2 = PublishEvent() + event2.hashed = true + event2.units = [Unit(type: "user_id", uid: "user456")] + event2.publishedAt = 1_620_000_001_000 + + httpClient.putUrlQueryHeadersBodyReturnValue = promise1 + _ = client.publish(event: event1) + + httpClient.putUrlQueryHeadersBodyReturnValue = promise2 + _ = client.publish(event: event2) + + XCTAssertEqual(2, httpClient.putUrlQueryHeadersBodyCallsCount) + } + + func testGetContextDataRejectsOn404() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.getUrlQueryHeadersReturnValue = promise + + let expectation = XCTestExpectation() + + let result = client.getContextData() + + result.done { _ in + XCTFail("Expected rejection") + }.catch { error in + XCTAssertTrue(error is ABSmartlyHTTPError) + let httpError = error as! ABSmartlyHTTPError + XCTAssertEqual(404, httpError.statusCode) + expectation.fulfill() + } + + let response = DefaultHTTPResponse( + status: 404, statusMessage: "Not Found", contentType: "text/plain", content: Data()) + resolver.fulfill(response) + + wait(for: [expectation], timeout: 1.0) + } + + func testPublishRejectsOn401() { + guard let client = client, let httpClient = httpClient else { return } + + let (promise, resolver) = Promise.pending() + httpClient.putUrlQueryHeadersBodyReturnValue = promise + + let expectation = XCTestExpectation() + + let event = PublishEvent() + event.hashed = true + event.units = [Unit(type: "session_id", uid: "abc123")] + event.publishedAt = 1_620_000_000_000 + + let result = client.publish(event: event) + + result.done { + XCTFail("Expected rejection") + }.catch { error in + XCTAssertTrue(error is ABSmartlyHTTPError) + let httpError = error as! ABSmartlyHTTPError + XCTAssertEqual(401, httpError.statusCode) + expectation.fulfill() + } + + let response = DefaultHTTPResponse( + status: 401, statusMessage: "Unauthorized", contentType: "text/plain", content: Data()) + resolver.fulfill(response) + + wait(for: [expectation], timeout: 1.0) + } } diff --git a/Tests/ABSmartlyTests/DefaultHTTPClientTest.swift b/Tests/ABSmartlyTests/DefaultHTTPClientTest.swift index 9899d73..ccca8ce 100644 --- a/Tests/ABSmartlyTests/DefaultHTTPClientTest.swift +++ b/Tests/ABSmartlyTests/DefaultHTTPClientTest.swift @@ -1,8 +1,43 @@ import Foundation +import PromiseKit import XCTest @testable import ABSmartly +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +private class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.unknown)) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + final class DefaultHTTPClientTest: XCTestCase { func testCreatable() throws { let config = DefaultHTTPClientConfig() @@ -10,4 +45,105 @@ final class DefaultHTTPClientTest: XCTestCase { let httpClient = DefaultHTTPClient(config: config) _ = httpClient.close() } + + func testURLSessionConfiguration() throws { + let config = DefaultHTTPClientConfig() + config.connectionRequestTimeout = 30.0 + config.connectionResourceTimeout = 60.0 + config.retries = 3 + config.retryInterval = 1.0 + + let httpClient = DefaultHTTPClient(config: config) + + XCTAssertNotNil(httpClient) + + _ = httpClient.close() + } + + func testDefaultHTTPResponse() throws { + let response = DefaultHTTPResponse( + status: 200, + statusMessage: "OK", + contentType: "application/json", + content: "{\"key\": \"value\"}".data(using: .utf8)! + ) + + XCTAssertEqual(200, response.status) + XCTAssertEqual("OK", response.statusMessage) + XCTAssertEqual("application/json", response.contentType) + XCTAssertNotNil(response.content) + } + + func testBadURL() throws { + let config = DefaultHTTPClientConfig() + config.retries = 0 + let httpClient = DefaultHTTPClient(config: config) + + let expectation = XCTestExpectation(description: "Bad URL fails") + + _ = httpClient.get(url: "not-a-valid-url", query: nil, headers: nil) + .done { _ in + XCTFail("Request should have failed with bad URL") + expectation.fulfill() + } + .catch { error in + XCTAssertNotNil(error) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + + _ = httpClient.close() + } + + func testCloseInvalidatesSession() throws { + let config = DefaultHTTPClientConfig() + let httpClient = DefaultHTTPClient(config: config) + + let closePromise = httpClient.close() + + let expectation = XCTestExpectation(description: "Close completes") + + _ = closePromise.done { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + + let requestExpectation = XCTestExpectation(description: "Request after close fails") + + _ = httpClient.get(url: "https://example.test/200", query: nil, headers: nil) + .done { _ in + XCTFail("Request should fail after close") + requestExpectation.fulfill() + } + .catch { error in + XCTAssertNotNil(error) + requestExpectation.fulfill() + } + + wait(for: [requestExpectation], timeout: 5.0) + } + + func testRetryStaticMethod() throws { + var attempts: UInt = 0 + let expectation = XCTestExpectation(description: "Retry completes") + + _ = DefaultHTTPClient.retry(times: 3, delay: 0.01) { attempt -> Promise in + attempts = attempt + if attempt < 3 { + return Promise(error: ABSmartlyError("transient")) + } + return Promise.value("success") + }.done { value in + XCTAssertEqual("success", value) + XCTAssertEqual(3, attempts) + expectation.fulfill() + }.catch { _ in + XCTFail("Should have succeeded after retries") + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } } diff --git a/Tests/ABSmartlyTests/DefaultVariableParserTest.swift b/Tests/ABSmartlyTests/DefaultVariableParserTest.swift index f1687e9..f958c1c 100644 --- a/Tests/ABSmartlyTests/DefaultVariableParserTest.swift +++ b/Tests/ABSmartlyTests/DefaultVariableParserTest.swift @@ -5,7 +5,7 @@ import XCTest final class DefaultVariableParserTest: XCTestCase { func testParse() throws { - let path = Bundle.module.path(forResource: "variables", ofType: "json", inDirectory: "Resources")! + let path = TestResources.path(forResource: "variables", ofType: "json") let data = try Foundation.Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) let config = String(data: data, encoding: .utf8) @@ -34,7 +34,7 @@ final class DefaultVariableParserTest: XCTestCase { } func testReturnsNilOnError() throws { - let path = Bundle.module.path(forResource: "variables", ofType: "json", inDirectory: "Resources")! + let path = TestResources.path(forResource: "variables", ofType: "json") let data = try Foundation.Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) let config = String(data: data.subdata(in: 0..<6), encoding: .utf8) diff --git a/Tests/ABSmartlyTests/HTTPIntegrationTest.swift b/Tests/ABSmartlyTests/HTTPIntegrationTest.swift new file mode 100644 index 0000000..85ed69d --- /dev/null +++ b/Tests/ABSmartlyTests/HTTPIntegrationTest.swift @@ -0,0 +1,331 @@ +import Foundation +import Network +import PromiseKit +import XCTest + +@testable import ABSmartly + +/// Hermetic integration test that exercises the REAL SDK HTTP client (URLSession, +/// built inside DefaultHTTPClient) against a REAL local HTTP server bound to +/// 127.0.0.1 on an ephemeral port. No URLProtocol mocks — the production client +/// makes genuine TCP/HTTP requests so this verifies the actual wire contract. +/// +/// Wire contract under test: +/// - GET /context?application=&environment= (no auth headers on GET) +/// - PUT /context with X-API-Key / X-Application / X-Environment / +/// X-Application-Version / X-Agent / Content-Type headers and a JSON +/// body containing hashed, units, publishedAt (+ goals/exposures). +final class HTTPIntegrationTest: XCTestCase { + private var server: LocalHTTPServer! + + override func setUpWithError() throws { + server = try LocalHTTPServer() + try server.start() + } + + override func tearDownWithError() throws { + server.stop() + server = nil + } + + func testRealHTTPGetAndPublish() throws { + let apiKey = "integration-test-key" + let application = "integration-app" + let environment = "integration-env" + + // Server returns an empty experiments set on GET so the context becomes ready. + server.getResponseBody = #"{"experiments":[]}"# + + let sdk = try ABsmartlySDK( + endpoint: "http://127.0.0.1:\(server.port)", + apiKey: apiKey, + application: application, + environment: environment + ) + + // --- GET /context (drive context to ready via the real client) --- + let contextConfig = ContextConfig() + contextConfig.setUnit(unitType: "session_id", uid: "bleh@absmartly.com") + + let context = sdk.createContext(config: contextConfig) + + let readyExpectation = expectation(description: "context ready") + context.waitUntilReady().done { _ in + readyExpectation.fulfill() + }.catch { error in + XCTFail("waitUntilReady failed: \(error)") + } + wait(for: [readyExpectation], timeout: 10.0) + + XCTAssertTrue(context.isReady()) + XCTAssertFalse(context.isFailed(), "context should not have failed: \(String(describing: context.readyError()))") + + // Assert the GET landed on the right path with the right query params. + let getRequest = server.waitForRequest(method: "GET", timeout: 10.0) + XCTAssertNotNil(getRequest, "expected a GET request to be received") + guard let get = getRequest else { return } + XCTAssertEqual(get.method, "GET") + XCTAssertEqual(get.path, "/context") + XCTAssertEqual(get.query["application"], application) + XCTAssertEqual(get.query["environment"], environment) + + // --- PUT /context (publish a queued exposure + goal) --- + server.putResponseBody = "{}" + + _ = context.getTreatment("exp_test_ab") // queue an exposure + context.track("payment", properties: ["amount": 100]) // queue a goal + + let publishExpectation = expectation(description: "publish completed") + context.publish().done { + publishExpectation.fulfill() + }.catch { error in + XCTFail("publish failed: \(error)") + } + wait(for: [publishExpectation], timeout: 10.0) + + let putRequest = server.waitForRequest(method: "PUT", timeout: 10.0) + XCTAssertNotNil(putRequest, "expected a PUT request to be received") + guard let put = putRequest else { return } + + XCTAssertEqual(put.method, "PUT") + XCTAssertEqual(put.path, "/context") + + // Headers (case-insensitive lookup). + XCTAssertEqual(put.header("X-API-Key"), apiKey) + XCTAssertEqual(put.header("X-Application"), application) + XCTAssertEqual(put.header("X-Environment"), environment) + XCTAssertEqual(put.header("X-Application-Version"), "0") + let agent = put.header("X-Agent") + XCTAssertNotNil(agent, "X-Agent header must be present") + XCTAssertFalse((agent ?? "").isEmpty, "X-Agent header must be non-empty") + let contentType = put.header("Content-Type") ?? "" + XCTAssertTrue(contentType.contains("application/json"), "Content-Type should be application/json, got '\(contentType)'") + + // Body JSON fields. + let bodyData = Data(put.body.utf8) + let json = try XCTUnwrap( + try JSONSerialization.jsonObject(with: bodyData) as? [String: Any], + "PUT body should be a JSON object, got: \(put.body)") + + XCTAssertNotNil(json["hashed"], "body must contain 'hashed'") + XCTAssertEqual(json["hashed"] as? Bool, true) + + let units = try XCTUnwrap(json["units"] as? [[String: Any]], "body must contain 'units' array") + XCTAssertFalse(units.isEmpty, "units should not be empty") + XCTAssertNotNil(units.first?["type"]) + XCTAssertNotNil(units.first?["uid"]) + + XCTAssertNotNil(json["publishedAt"], "body must contain 'publishedAt'") + XCTAssertTrue(json["publishedAt"] is NSNumber, "publishedAt should be a number") + + // We queued one exposure and one goal, so both arrays should be present. + let exposures = try XCTUnwrap(json["exposures"] as? [[String: Any]], "body should contain 'exposures'") + XCTAssertFalse(exposures.isEmpty, "exposures should not be empty") + let goals = try XCTUnwrap(json["goals"] as? [[String: Any]], "body should contain 'goals'") + XCTAssertFalse(goals.isEmpty, "goals should not be empty") + + _ = sdk.close() + } +} + +// MARK: - Minimal localhost HTTP server (NWListener-based) + +/// A tiny, hermetic HTTP/1.1 server bound to 127.0.0.1 on an ephemeral port. +/// It parses request line + headers + (Content-Length) body, records each +/// request, and replies 200 with a small JSON body. Just enough to exercise the +/// SDK's real URLSession client; not a general-purpose server. +private final class LocalHTTPServer { + struct Request { + let method: String + let path: String + let query: [String: String] + let headers: [String: String] // keys lowercased + let body: String + + func header(_ name: String) -> String? { + return headers[name.lowercased()] + } + } + + var getResponseBody: String = #"{"experiments":[]}"# + var putResponseBody: String = "{}" + + private let listener: NWListener + private let queue = DispatchQueue(label: "local-http-server") + private let lock = NSLock() + private var requests: [Request] = [] + + var port: UInt16 { + return listener.port?.rawValue ?? 0 + } + + init() throws { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + // Bind explicitly to loopback on a kernel-chosen ephemeral port. + params.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: .any) + listener = try NWListener(using: params) + } + + func start() throws { + let ready = DispatchSemaphore(value: 0) + listener.stateUpdateHandler = { state in + if case .ready = state { + ready.signal() + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection) + } + listener.start(queue: queue) + + guard ready.wait(timeout: .now() + 5.0) == .success else { + throw ABSmartlyError("Local HTTP server failed to reach ready state") + } + guard port != 0 else { + throw ABSmartlyError("Local HTTP server did not bind to a port") + } + } + + func stop() { + listener.cancel() + } + + /// Block until a request with the given method has been recorded (or timeout). + func waitForRequest(method: String, timeout: TimeInterval) -> Request? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + lock.lock() + let found = requests.first { $0.method == method } + lock.unlock() + if let found = found { + return found + } + Thread.sleep(forTimeInterval: 0.02) + } + return nil + } + + // MARK: connection handling + + private func handle(_ connection: NWConnection) { + connection.start(queue: queue) + receive(connection, buffer: Data()) + } + + private func receive(_ connection: NWConnection, buffer: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { + [weak self] data, _, isComplete, error in + guard let self = self else { + connection.cancel() + return + } + + var accumulated = buffer + if let data = data { + accumulated.append(data) + } + + if let parsed = self.tryParse(accumulated) { + self.lock.lock() + self.requests.append(parsed.request) + self.lock.unlock() + self.respond(connection, to: parsed.request) + return + } + + if let error = error { + _ = error + connection.cancel() + return + } + + if isComplete { + connection.cancel() + return + } + + // Need more bytes (headers or body incomplete). + self.receive(connection, buffer: accumulated) + } + } + + /// Parse a full HTTP request once headers + (any) body are present. + /// Returns nil if more bytes are needed. + private func tryParse(_ data: Data) -> (request: Request, consumed: Int)? { + guard let headerEndRange = data.range(of: Data("\r\n\r\n".utf8)) else { + return nil + } + + let headerData = data.subdata(in: data.startIndex..= 2 else { return nil } + let method = requestParts[0] + let target = requestParts[1] + + var headers: [String: String] = [:] + for line in lines where !line.isEmpty { + guard let colon = line.firstIndex(of: ":") else { continue } + let name = String(line[line.startIndex.. (String, [String: String]) { + guard let qIndex = target.firstIndex(of: "?") else { + return (target, [:]) + } + let path = String(target[target.startIndex.. UInt32 { + let key: [UInt8] = Array(input.utf8) + return MurmurHash.murmurHash(key, seed) + } + + func testSeed0EmptyString() { + XCTAssertEqual(murmur("", 0x0000_0000), 0x0000_0000) + } + + func testSeed0Space() { + XCTAssertEqual(murmur(" ", 0x0000_0000), 0x7ef4_9b98) + } + + func testSeed0T() { + XCTAssertEqual(murmur("t", 0x0000_0000), 0xca87_df4d) + } + + func testSeed0Te() { + XCTAssertEqual(murmur("te", 0x0000_0000), 0xedb8_ee1b) + } + + func testSeed0Tes() { + XCTAssertEqual(murmur("tes", 0x0000_0000), 0x0bb9_0e5a) + } + + func testSeed0Test() { + XCTAssertEqual(murmur("test", 0x0000_0000), 0xba6b_d213) + } + + func testSeed0Testy() { + XCTAssertEqual(murmur("testy", 0x0000_0000), 0x44af_8342) + } + + func testSeed0Testy1() { + XCTAssertEqual(murmur("testy1", 0x0000_0000), 0x8a1a_243a) + } + + func testSeed0Testy12() { + XCTAssertEqual(murmur("testy12", 0x0000_0000), 0x8454_61b9) + } + + func testSeed0Testy123() { + XCTAssertEqual(murmur("testy123", 0x0000_0000), 0x4762_8ac4) + } + + func testSeed0SpecialCharacters() { + XCTAssertEqual(murmur("special characters açb↓c", 0x0000_0000), 0xbe83_b140) + } + + func testSeed0QuickBrownFox() { + XCTAssertEqual(murmur("The quick brown fox jumps over the lazy dog", 0x0000_0000), 0x2e4f_f723) + } + + func testSeedDeadbeefEmptyString() { + XCTAssertEqual(murmur("", 0xdead_beef), 0x0de5_c6a9) + } + + func testSeedDeadbeefSpace() { + XCTAssertEqual(murmur(" ", 0xdead_beef), 0x25ac_ce43) + } + + func testSeedDeadbeefT() { + XCTAssertEqual(murmur("t", 0xdead_beef), 0x3b15_dcf8) + } + + func testSeedDeadbeefTe() { + XCTAssertEqual(murmur("te", 0xdead_beef), 0xac98_1332) + } + + func testSeedDeadbeefTes() { + XCTAssertEqual(murmur("tes", 0xdead_beef), 0xc1c7_8dda) + } + + func testSeedDeadbeefTest() { + XCTAssertEqual(murmur("test", 0xdead_beef), 0xaa22_d41a) + } + + func testSeedDeadbeefTesty() { + XCTAssertEqual(murmur("testy", 0xdead_beef), 0x84f5_f623) + } + + func testSeedDeadbeefTesty1() { + XCTAssertEqual(murmur("testy1", 0xdead_beef), 0x09ed_28e9) + } + + func testSeedDeadbeefTesty12() { + XCTAssertEqual(murmur("testy12", 0xdead_beef), 0x2246_7835) + } + + func testSeedDeadbeefTesty123() { + XCTAssertEqual(murmur("testy123", 0xdead_beef), 0xd633_060d) + } + + func testSeedDeadbeefSpecialCharacters() { + XCTAssertEqual(murmur("special characters açb↓c", 0xdead_beef), 0xf7fd_d8a2) + } + + func testSeedDeadbeefQuickBrownFox() { + XCTAssertEqual(murmur("The quick brown fox jumps over the lazy dog", 0xdead_beef), 0x3a7b_3f4d) + } + + func testSeed1EmptyString() { + XCTAssertEqual(murmur("", 0x0000_0001), 0x514e_28b7) + } + + func testSeed1Space() { + XCTAssertEqual(murmur(" ", 0x0000_0001), 0x4f0f_7132) + } + + func testSeed1T() { + XCTAssertEqual(murmur("t", 0x0000_0001), 0x5db1_831e) + } + + func testSeed1Te() { + XCTAssertEqual(murmur("te", 0x0000_0001), 0xd248_bb2e) + } + + func testSeed1Tes() { + XCTAssertEqual(murmur("tes", 0x0000_0001), 0xd432_eb74) + } + + func testSeed1Test() { + XCTAssertEqual(murmur("test", 0x0000_0001), 0x99c0_2ae2) + } + + func testSeed1Testy() { + XCTAssertEqual(murmur("testy", 0x0000_0001), 0xc5b2_dc1e) + } + + func testSeed1Testy1() { + XCTAssertEqual(murmur("testy1", 0x0000_0001), 0x3392_5ceb) + } + + func testSeed1Testy12() { + XCTAssertEqual(murmur("testy12", 0x0000_0001), 0xd92c_9f23) + } + + func testSeed1Testy123() { + XCTAssertEqual(murmur("testy123", 0x0000_0001), 0x3bc1_712d) + } + + func testSeed1SpecialCharacters() { + XCTAssertEqual(murmur("special characters açb↓c", 0x0000_0001), 0x2933_27b5) + } + + func testSeed1QuickBrownFox() { + XCTAssertEqual(murmur("The quick brown fox jumps over the lazy dog", 0x0000_0001), 0x78e6_9e27) } } diff --git a/Tests/ABSmartlyTests/JsonExpr/Operators/BinaryOperatorNullSafetyTest.swift b/Tests/ABSmartlyTests/JsonExpr/Operators/BinaryOperatorNullSafetyTest.swift new file mode 100644 index 0000000..d6e9f1c --- /dev/null +++ b/Tests/ABSmartlyTests/JsonExpr/Operators/BinaryOperatorNullSafetyTest.swift @@ -0,0 +1,75 @@ +import Foundation +import XCTest + +@testable import ABSmartly + +final class BinaryOperatorNullSafetyTest: OperatorTest { + let equalsOp = EqualsOperator() + let greaterOp = GreaterThanOperator() + let lessOp = LessThanOperator() + let greaterEqOp = GreaterThanOrEqualOperator() + let lessEqOp = LessThanOrEqualOperator() + let matchOp = MatchOperator() + let inOp = InOperator() + + func testEqualsNullNull() { + let result = equalsOp.evaluate(evaluator, [JSON.null, JSON.null]) + XCTAssertEqual(JSON.null, result) + } + + func testEqualsNullVsNumber() { + evaluator.clearInvocations() + let result = equalsOp.evaluate(evaluator, [JSON.null, 1]) + XCTAssertFalse(result.boolValue) + } + + func testEqualsNumberVsNull() { + evaluator.clearInvocations() + let result = equalsOp.evaluate(evaluator, [1, JSON.null]) + XCTAssertFalse(result.boolValue) + } + + func testGreaterThanNullNull() { + let result = greaterOp.evaluate(evaluator, [JSON.null, JSON.null]) + XCTAssertFalse(result.boolValue) + } + + func testLessThanNullNull() { + let result = lessOp.evaluate(evaluator, [JSON.null, JSON.null]) + XCTAssertFalse(result.boolValue) + } + + func testGreaterThanOrEqualNullNull() { + let result = greaterEqOp.evaluate(evaluator, [JSON.null, JSON.null]) + XCTAssertEqual(JSON.null, result) + } + + func testLessThanOrEqualNullNull() { + let result = lessEqOp.evaluate(evaluator, [JSON.null, JSON.null]) + XCTAssertEqual(JSON.null, result) + } + + func testMatchWithNullLhsDoesNotCrash() { + let result = matchOp.evaluate(evaluator, [JSON.null, "abc"]) + XCTAssertNotNil(result) + } + + func testMatchWithNullRhsDoesNotCrash() { + let result = matchOp.evaluate(evaluator, ["abc", JSON.null]) + XCTAssertNotNil(result) + } + + func testInWithNullHaystack() { + let result = inOp.evaluate(evaluator, ["abc", JSON.null]) + XCTAssertEqual(JSON.null, result) + } + + func testBinaryOperatorNotEnoughArgs() { + let result = equalsOp.evaluate(evaluator, [1]) + XCTAssertEqual(JSON.null, result) + + evaluator.clearInvocations() + let result2 = equalsOp.evaluate(evaluator, JSON.null) + XCTAssertEqual(JSON.null, result2) + } +} diff --git a/Tests/ABSmartlyTests/JsonExpr/Operators/MatchOperatorTest.swift b/Tests/ABSmartlyTests/JsonExpr/Operators/MatchOperatorTest.swift index ad10ea1..c045600 100644 --- a/Tests/ABSmartlyTests/JsonExpr/Operators/MatchOperatorTest.swift +++ b/Tests/ABSmartlyTests/JsonExpr/Operators/MatchOperatorTest.swift @@ -16,7 +16,44 @@ final class MatchOperatorTest: OperatorTest { XCTAssertTrue(matchOperator.evaluate(evaluator, ["abcdefghijk", "b.*j"]).boolValue) XCTAssertFalse(matchOperator.evaluate(evaluator, ["abcdefghijk", "xyz"]).boolValue) - XCTAssertEqual(JSON.null, matchOperator.evaluate(evaluator, [JSON.null, "abc"])) + XCTAssertFalse(matchOperator.evaluate(evaluator, [JSON.null, "abc"]).boolValue) XCTAssertEqual(JSON.null, matchOperator.evaluate(evaluator, ["abcdefghijk", JSON.null])) } + + func testRejectsLongPattern() { + let longPattern = String(repeating: "a", count: 1001) + let result = matchOperator.evaluate(evaluator, ["test", longPattern]) + XCTAssertEqual(JSON.null, result) + } + + func testRejectsLongInput() { + let longInput = String(repeating: "a", count: 10001) + let result = matchOperator.evaluate(evaluator, [longInput, "a"]) + XCTAssertEqual(JSON.null, result) + } + + func testRejectsNestedQuantifiers() { + let result = matchOperator.evaluate(evaluator, ["test", "(a+)+b"]) + XCTAssertEqual(JSON.null, result) + } + + func testAcceptsNormalPatternWithinLimits() { + let pattern = String(repeating: "a", count: 999) + let input = String(repeating: "a", count: 9999) + let result = matchOperator.evaluate(evaluator, [input, pattern]) + XCTAssertTrue(result.boolValue) + } + + func testNoSemaphoreThreadLeak() { + let result = matchOperator.evaluate(evaluator, ["hello world", "hello"]) + XCTAssertTrue(result.boolValue) + + let result2 = matchOperator.evaluate(evaluator, ["hello world", "^world"]) + XCTAssertFalse(result2.boolValue) + } + + func testInvalidRegexReturnsNull() { + let result = matchOperator.evaluate(evaluator, ["test", "[invalid"]) + XCTAssertEqual(JSON.null, result) + } } diff --git a/Tests/ABSmartlyTests/Mocks/SourceryGenerated.swift b/Tests/ABSmartlyTests/Mocks/SourceryGenerated.swift index 922ae44..a62badf 100644 --- a/Tests/ABSmartlyTests/Mocks/SourceryGenerated.swift +++ b/Tests/ABSmartlyTests/Mocks/SourceryGenerated.swift @@ -163,6 +163,7 @@ class ContextEventLoggerMock: ContextEventLogger { //MARK: - handleEvent + private let lock = NSLock() var handleEventContextEventCallsCount = 0 var handleEventContextEventCalled: Bool { return handleEventContextEventCallsCount > 0 @@ -172,16 +173,20 @@ class ContextEventLoggerMock: ContextEventLogger { var handleEventContextEventClosure: ((Context, ContextEventLoggerEvent) -> Void)? func handleEvent(context: Context, event: ContextEventLoggerEvent) { + lock.lock() handleEventContextEventCallsCount += 1 handleEventContextEventReceivedArguments = (context: context, event: event) handleEventContextEventReceivedInvocations.append((context: context, event: event)) + lock.unlock() handleEventContextEventClosure?(context, event) } func clearInvocations() { + lock.lock() handleEventContextEventCallsCount = 0 handleEventContextEventReceivedArguments = nil handleEventContextEventReceivedInvocations = [] + lock.unlock() } } diff --git a/Tests/ABSmartlyTests/PerformanceTests.swift b/Tests/ABSmartlyTests/PerformanceTests.swift new file mode 100644 index 0000000..44ec08e --- /dev/null +++ b/Tests/ABSmartlyTests/PerformanceTests.swift @@ -0,0 +1,211 @@ +import Foundation +import PromiseKit +import XCTest + +@testable import ABSmartly + +final class PerformanceTests: XCTestCase { + var provider: ContextDataProviderMock = ContextDataProviderMock() + var handler: ContextEventHandlerMock = ContextEventHandlerMock() + var logger: ContextEventLoggerMock = ContextEventLoggerMock() + var parser: VariableParser = DefaultVariableParser() + var scheduler: SchedulerMock = SchedulerMock() + var clock: ClockMock = ClockMock() + + let units = [ + "email": "bleh@absmartly.com", + "session_id": "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + "user_id": "123456789", + ] + + override func setUp() async throws { + provider = ContextDataProviderMock() + handler = ContextEventHandlerMock() + logger = ContextEventLoggerMock() + parser = DefaultVariableParser() + scheduler = SchedulerMock() + scheduler.scheduleAfterExecuteReturnValue = ScheduledHandleMock() + scheduler.scheduleWithFixedDelayAfterRepeatingExecuteReturnValue = ScheduledHandleMock() + clock.millisReturnValue = 1_620_000_000_000 + } + + func getContextData(source: String = "context") throws -> ContextData { + let path = TestResources.path(forResource: source, ofType: "json") + let data = try Foundation.Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) + return try JSONDecoder().decode(ContextData.self, from: data) + } + + func createContext(config: ContextConfig, data: Promise? = nil) throws -> Context { + let data = try data ?? Promise.value(try getContextData()) + return Context( + config: config, clock: clock, scheduler: scheduler, handler: handler, provider: provider, logger: logger, + parser: parser, matcher: AudienceMatcher(), + promise: data) + } + + func getContextConfig(withUnits: Bool = false) -> ContextConfig { + let contextConfig: ContextConfig = ContextConfig() + + if withUnits { + contextConfig.setUnits(units: units) + } + + return contextConfig + } + + func testLargeContextDataHandling() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + XCTAssertTrue(context.isReady()) + + let experiments = context.getExperiments() + XCTAssertFalse(experiments.isEmpty) + + self.measure { + for experimentName in experiments { + _ = context.peekTreatment(experimentName) + } + } + + XCTAssertEqual(0, context.getPendingCount()) + } + + func testHighFrequencyOperations() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + self.measure { + for i in 0..<1000 { + context.setAttribute(name: "attr_\(i % 100)", value: JSON("value_\(i)")) + } + } + + let attrs = context.getAttributes() + XCTAssertGreaterThan(attrs.count, 0) + } + + func testTreatmentAccessPerformance() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + let experimentNames = context.getExperiments() + + self.measure { + for _ in 0..<100 { + for experimentName in experimentNames { + _ = context.getTreatment(experimentName) + } + } + } + + XCTAssertGreaterThan(context.getPendingCount(), 0) + } + + func testGoalTrackingPerformance() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + let pendingBefore = context.getPendingCount() + + self.measure { + for i in 0..<100 { + context.track("goal_\(i % 10)", properties: ["iteration": JSON(i)]) + } + } + + let queuedDuringMeasure = Int(context.getPendingCount()) - Int(pendingBefore) + XCTAssertGreaterThanOrEqual(queuedDuringMeasure, 100) + XCTAssertEqual(0, queuedDuringMeasure % 100) + } + + func testVariableAccessPerformance() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + let variableKeys = ["banner.border", "banner.size", "button.color", "submit.color", "submit.shape"] + + self.measure { + for _ in 0..<100 { + for key in variableKeys { + _ = context.peekVariableValue(key, defaultValue: nil) + } + } + } + } + + func testOverrideSettingPerformance() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + self.measure { + for i in 0..<1000 { + context.setOverride(experimentName: "exp_\(i % 100)", variant: i % 5) + } + } + + let override = context.getOverride(experimentName: "exp_50") + XCTAssertNotNil(override) + } + + func testCustomAssignmentPerformance() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + self.measure { + for i in 0..<1000 { + context.setCustomAssignment(experimentName: "exp_\(i % 100)", variant: i % 5) + } + } + + let assignment = context.getCustomAssignment(experimentName: "exp_50") + XCTAssertNotNil(assignment) + } + + func testUnitSettingPerformance() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: false) + let context = try createContext(config: contextConfig) + + self.measure { + for i in 0..<100 { + context.setUnit(unitType: "unit_\(i)", uid: "uid_\(i)") + } + } + + let units = context.getUnits() + XCTAssertEqual(100, units.count) + } + + func testContextCreationPerformance() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let contextData = try getContextData() + + self.measure { + for _ in 0..<10 { + let context = Context( + config: contextConfig, clock: clock, scheduler: scheduler, handler: handler, + provider: provider, logger: logger, + parser: parser, matcher: AudienceMatcher(), + promise: Promise.value(contextData)) + XCTAssertTrue(context.isReady()) + } + } + } + + func testCacheMemoryUsage() throws { + let contextConfig: ContextConfig = getContextConfig(withUnits: true) + let context = try createContext(config: contextConfig) + + let experiments = context.getExperiments() + for experimentName in experiments { + _ = context.getTreatment(experimentName) + } + + XCTAssertEqual(UInt(experiments.count), context.getPendingCount()) + + for i in 0..<100 { + context.track("goal_\(i)", properties: ["data": JSON(String(repeating: "x", count: 100))]) + } + + XCTAssertEqual(UInt(experiments.count) + 100, context.getPendingCount()) + } +} diff --git a/Tests/ABSmartlyTests/Resources/refreshed_iteration.json b/Tests/ABSmartlyTests/Resources/refreshed_iteration.json new file mode 100644 index 0000000..7ad3062 --- /dev/null +++ b/Tests/ABSmartlyTests/Resources/refreshed_iteration.json @@ -0,0 +1,194 @@ +{ + "experiments":[ + { + "id":1, + "name":"exp_test_ab", + "iteration":1, + "unitType":"session_id", + "seedHi":3603515, + "seedLo":233373850, + "split":[ + 0.5, + 0.5 + ], + "trafficSeedHi":449867249, + "trafficSeedLo":455443629, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":0, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"banner.border\":1,\"banner.size\":\"large\"}" + } + ] + }, + { + "id":2, + "name":"exp_test_abc", + "iteration":2, + "unitType":"session_id", + "seedHi":55006150, + "seedLo":47189152, + "split":[ + 0.34, + 0.33, + 0.33 + ], + "trafficSeedHi":705671872, + "trafficSeedLo":212903484, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":0, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"button.color\":\"blue\"}" + }, + { + "name":"C", + "config":"{\"button.color\":\"red\"}" + } + ] + }, + { + "id":3, + "name":"exp_test_not_eligible", + "iteration":1, + "unitType":"user_id", + "seedHi":503266407, + "seedLo":144942754, + "split":[ + 0.34, + 0.33, + 0.33 + ], + "trafficSeedHi":87768905, + "trafficSeedLo":511357582, + "trafficSplit":[ + 0.99, + 0.01 + ], + "fullOnVariant":0, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"card.width\":\"80%\"}" + }, + { + "name":"C", + "config":"{\"card.width\":\"75%\"}" + } + ] + }, + { + "id":4, + "name":"exp_test_fullon", + "iteration":1, + "unitType":"session_id", + "seedHi":856061641, + "seedLo":990838475, + "split":[ + 0.25, + 0.25, + 0.25, + 0.25 + ], + "trafficSeedHi":360868579, + "trafficSeedLo":330937933, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":2, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"submit.color\":\"red\",\"submit.shape\":\"circle\"}" + }, + { + "name":"C", + "config":"{\"submit.color\":\"blue\",\"submit.shape\":\"rect\"}" + }, + { + "name":"D", + "config":"{\"submit.color\":\"green\",\"submit.shape\":\"square\"}" + } + ] + }, + { + "id":5, + "name":"exp_test_new", + "iteration":2, + "unitType":"session_id", + "seedHi":934590467, + "seedLo":714771373, + "split":[ + 0.5, + 0.5 + ], + "trafficSeedHi":940553836, + "trafficSeedLo":270705624, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":1, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"show-modal\":true}" + } + ] + } + ] +} diff --git a/Tests/ABSmartlyTests/TestResources.swift b/Tests/ABSmartlyTests/TestResources.swift new file mode 100644 index 0000000..c1d3d05 --- /dev/null +++ b/Tests/ABSmartlyTests/TestResources.swift @@ -0,0 +1,19 @@ +import Foundation + +enum TestResources { + #if canImport(FoundationNetworking) + private static let resourcesDir: String = { + let thisFile = #filePath + let testsDir = (thisFile as NSString).deletingLastPathComponent + return testsDir + "/Resources" + }() + + static func path(forResource name: String, ofType ext: String) -> String { + return resourcesDir + "/" + name + "." + ext + } + #else + static func path(forResource name: String, ofType ext: String) -> String { + return Bundle.module.path(forResource: name, ofType: ext, inDirectory: "Resources")! + } + #endif +} diff --git a/Tests/ABSmartlyTests/VariantAssignerTest.swift b/Tests/ABSmartlyTests/VariantAssignerTest.swift index 0f488fa..9ff530d 100644 --- a/Tests/ABSmartlyTests/VariantAssignerTest.swift +++ b/Tests/ABSmartlyTests/VariantAssignerTest.swift @@ -5,8 +5,7 @@ import XCTest final class VariantAssignerTest: XCTestCase { - func testSetUnit() { - + func testChooseVariant() { XCTAssertEqual(1, VariantAssigner.chooseVariant([0, 1], 0)) XCTAssertEqual(1, VariantAssigner.chooseVariant([0, 1], 0.5)) XCTAssertEqual(1, VariantAssigner.chooseVariant([0, 1], 1)) @@ -39,57 +38,178 @@ final class VariantAssignerTest: XCTestCase { XCTAssertEqual(1, VariantAssigner.chooseVariant([0, 1], 1)) } - func testAssignmentsMatch() { - let splits: [[Double]] = [ - [0.5, 0.5], - [0.5, 0.5], - [0.5, 0.5], - [0.5, 0.5], - [0.5, 0.5], - [0.5, 0.5], - [0.5, 0.5], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - ] - - let seeds: [[Int]] = [ - [0x0000_0000, 0x0000_0000], - [0x0000_0000, 0x0000_0001], - [0x8015_406f, 0x7ef4_9b98], - [0x3b2e_7d90, 0xca87_df4d], - [0x52c1_f657, 0xd248_bb2e], - [0x865a_84d0, 0xaa22_d41a], - [0x27d1_dc86, 0x8454_61b9], - [0x0000_0000, 0x0000_0000], - [0x0000_0000, 0x0000_0001], - [0x8015_406f, 0x7ef4_9b98], - [0x3b2e_7d90, 0xca87_df4d], - [0x52c1_f657, 0xd248_bb2e], - [0x865a_84d0, 0xaa22_d41a], - [0x27d1_dc86, 0x8454_61b9], - ] - - let source: [String: [Int]] = [ - "123456789": [1, 0, 1, 1, 1, 0, 0, 2, 1, 2, 2, 2, 0, 0], - "bleh@absmartly.com": [0, 1, 0, 0, 0, 0, 1, 0, 2, 0, 0, 0, 1, 1], - "e791e240fcd3df7d238cfc285f475e8152fcc0ec": [1, 0, 1, 1, 0, 0, 0, 2, 0, 2, 1, 0, 0, 1], - ] - - for item in source { - let unitHash: [UInt8] = Hashing.hash(item.key) - let assigner = VariantAssigner(unitHash) - - for i in 0...seeds.count - 1 { - let flags: [Int] = seeds[i] - let split: [Double] = splits[i] - let variant: Int = assigner.assign(split, flags[0], flags[1]) - XCTAssertEqual(variant, item.value[i]) - } - } + private func assertAssignment(_ unit: String, _ split: [Double], _ seedHi: Int, _ seedLo: Int, _ expected: Int, file: StaticString = #file, line: UInt = #line) { + let unitHash: [UInt8] = Hashing.hashBytes(unit) + let assigner = VariantAssigner(unitHash) + let variant = assigner.assign(split, seedHi, seedLo) + XCTAssertEqual(variant, expected, "Unit: \(unit), split: \(split), seeds: [\(seedHi), \(seedLo)]", file: file, line: line) + } + + func testEmailBinarySplit_ZeroSeeds() { + assertAssignment("bleh@absmartly.com", [0.5, 0.5], 0x0000_0000, 0x0000_0000, 0) + } + + func testEmailBinarySplit_ZeroHiOneLo() { + assertAssignment("bleh@absmartly.com", [0.5, 0.5], 0x0000_0000, 0x0000_0001, 1) + } + + func testEmailBinarySplit_Seeds1() { + assertAssignment("bleh@absmartly.com", [0.5, 0.5], 0x8015_406f, 0x7ef4_9b98, 0) + } + + func testEmailBinarySplit_Seeds2() { + assertAssignment("bleh@absmartly.com", [0.5, 0.5], 0x3b2e_7d90, 0xca87_df4d, 0) + } + + func testEmailBinarySplit_Seeds3() { + assertAssignment("bleh@absmartly.com", [0.5, 0.5], 0x52c1_f657, 0xd248_bb2e, 0) + } + + func testEmailBinarySplit_Seeds4() { + assertAssignment("bleh@absmartly.com", [0.5, 0.5], 0x865a_84d0, 0xaa22_d41a, 0) + } + + func testEmailBinarySplit_Seeds5() { + assertAssignment("bleh@absmartly.com", [0.5, 0.5], 0x27d1_dc86, 0x8454_61b9, 1) + } + + func testEmailThreeWaySplit_ZeroSeeds() { + assertAssignment("bleh@absmartly.com", [0.33, 0.33, 0.34], 0x0000_0000, 0x0000_0000, 0) + } + + func testEmailThreeWaySplit_ZeroHiOneLo() { + assertAssignment("bleh@absmartly.com", [0.33, 0.33, 0.34], 0x0000_0000, 0x0000_0001, 2) + } + + func testEmailThreeWaySplit_Seeds1() { + assertAssignment("bleh@absmartly.com", [0.33, 0.33, 0.34], 0x8015_406f, 0x7ef4_9b98, 0) + } + + func testEmailThreeWaySplit_Seeds2() { + assertAssignment("bleh@absmartly.com", [0.33, 0.33, 0.34], 0x3b2e_7d90, 0xca87_df4d, 0) + } + + func testEmailThreeWaySplit_Seeds3() { + assertAssignment("bleh@absmartly.com", [0.33, 0.33, 0.34], 0x52c1_f657, 0xd248_bb2e, 0) + } + + func testEmailThreeWaySplit_Seeds4() { + assertAssignment("bleh@absmartly.com", [0.33, 0.33, 0.34], 0x865a_84d0, 0xaa22_d41a, 1) + } + + func testEmailThreeWaySplit_Seeds5() { + assertAssignment("bleh@absmartly.com", [0.33, 0.33, 0.34], 0x27d1_dc86, 0x8454_61b9, 1) + } + + func testNumericBinarySplit_ZeroSeeds() { + assertAssignment("123456789", [0.5, 0.5], 0x0000_0000, 0x0000_0000, 1) + } + + func testNumericBinarySplit_ZeroHiOneLo() { + assertAssignment("123456789", [0.5, 0.5], 0x0000_0000, 0x0000_0001, 0) + } + + func testNumericBinarySplit_Seeds1() { + assertAssignment("123456789", [0.5, 0.5], 0x8015_406f, 0x7ef4_9b98, 1) + } + + func testNumericBinarySplit_Seeds2() { + assertAssignment("123456789", [0.5, 0.5], 0x3b2e_7d90, 0xca87_df4d, 1) + } + + func testNumericBinarySplit_Seeds3() { + assertAssignment("123456789", [0.5, 0.5], 0x52c1_f657, 0xd248_bb2e, 1) + } + + func testNumericBinarySplit_Seeds4() { + assertAssignment("123456789", [0.5, 0.5], 0x865a_84d0, 0xaa22_d41a, 0) + } + + func testNumericBinarySplit_Seeds5() { + assertAssignment("123456789", [0.5, 0.5], 0x27d1_dc86, 0x8454_61b9, 0) + } + + func testNumericThreeWaySplit_ZeroSeeds() { + assertAssignment("123456789", [0.33, 0.33, 0.34], 0x0000_0000, 0x0000_0000, 2) + } + + func testNumericThreeWaySplit_ZeroHiOneLo() { + assertAssignment("123456789", [0.33, 0.33, 0.34], 0x0000_0000, 0x0000_0001, 1) + } + + func testNumericThreeWaySplit_Seeds1() { + assertAssignment("123456789", [0.33, 0.33, 0.34], 0x8015_406f, 0x7ef4_9b98, 2) + } + + func testNumericThreeWaySplit_Seeds2() { + assertAssignment("123456789", [0.33, 0.33, 0.34], 0x3b2e_7d90, 0xca87_df4d, 2) + } + + func testNumericThreeWaySplit_Seeds3() { + assertAssignment("123456789", [0.33, 0.33, 0.34], 0x52c1_f657, 0xd248_bb2e, 2) + } + + func testNumericThreeWaySplit_Seeds4() { + assertAssignment("123456789", [0.33, 0.33, 0.34], 0x865a_84d0, 0xaa22_d41a, 0) + } + + func testNumericThreeWaySplit_Seeds5() { + assertAssignment("123456789", [0.33, 0.33, 0.34], 0x27d1_dc86, 0x8454_61b9, 0) + } + + func testHashStringBinarySplit_ZeroSeeds() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 0x0000_0000, 0x0000_0000, 1) + } + + func testHashStringBinarySplit_ZeroHiOneLo() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 0x0000_0000, 0x0000_0001, 0) + } + + func testHashStringBinarySplit_Seeds1() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 0x8015_406f, 0x7ef4_9b98, 1) + } + + func testHashStringBinarySplit_Seeds2() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 0x3b2e_7d90, 0xca87_df4d, 1) + } + + func testHashStringBinarySplit_Seeds3() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 0x52c1_f657, 0xd248_bb2e, 0) + } + + func testHashStringBinarySplit_Seeds4() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 0x865a_84d0, 0xaa22_d41a, 0) + } + + func testHashStringBinarySplit_Seeds5() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 0x27d1_dc86, 0x8454_61b9, 0) + } + + func testHashStringThreeWaySplit_ZeroSeeds() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 0x0000_0000, 0x0000_0000, 2) + } + + func testHashStringThreeWaySplit_ZeroHiOneLo() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 0x0000_0000, 0x0000_0001, 0) + } + + func testHashStringThreeWaySplit_Seeds1() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 0x8015_406f, 0x7ef4_9b98, 2) + } + + func testHashStringThreeWaySplit_Seeds2() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 0x3b2e_7d90, 0xca87_df4d, 1) + } + + func testHashStringThreeWaySplit_Seeds3() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 0x52c1_f657, 0xd248_bb2e, 0) + } + + func testHashStringThreeWaySplit_Seeds4() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 0x865a_84d0, 0xaa22_d41a, 0) + } + + func testHashStringThreeWaySplit_Seeds5() { + assertAssignment("e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 0x27d1_dc86, 0x8454_61b9, 1) } }