diff --git a/Package.resolved b/Package.resolved index 952c011..cc69eb7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,24 @@ { "object": { "pins": [ + { + "package": "CircuitBreaker", + "repositoryURL": "https://github.com/Kitura/CircuitBreaker.git", + "state": { + "branch": null, + "revision": "bd4255762e48cc3748a448d197f1297a4ba705f7", + "version": "5.1.0" + } + }, + { + "package": "LoggerAPI", + "repositoryURL": "https://github.com/Kitura/LoggerAPI.git", + "state": { + "branch": null, + "revision": "e82d34eab3f0b05391082b11ea07d3b70d2f65bb", + "version": "1.9.200" + } + }, { "package": "PromiseKit", "repositoryURL": "https://github.com/mxcl/PromiseKit.git", @@ -10,6 +28,15 @@ "version": "6.17.0" } }, + { + "package": "SQLite.swift", + "repositoryURL": "https://github.com/stephencelis/SQLite.swift.git", + "state": { + "branch": null, + "revision": "7a2e3cd27de56f6d396e84f63beefd0267b55ccb", + "version": "0.14.1" + } + }, { "package": "swift-atomics", "repositoryURL": "https://github.com/apple/swift-atomics.git", @@ -19,6 +46,15 @@ "version": "1.0.2" } }, + { + "package": "swift-log", + "repositoryURL": "https://github.com/apple/swift-log.git", + "state": { + "branch": null, + "revision": "32e8d724467f8fe623624570367e3d50c5638e46", + "version": "1.5.2" + } + }, { "package": "SwiftyJSON", "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git", diff --git a/Package.swift b/Package.swift index 1063173..ae817ab 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,8 @@ let package = Package( targets: ["ABSmartly"]) ], dependencies: [ + .package(url: "https://github.com/stephencelis/SQLite.swift.git", .upToNextMajor(from: "0.14.1")), + .package(url: "https://github.com/Kitura/CircuitBreaker.git", .upToNextMajor(from: "5.0.3")), .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: "4.3.0")) @@ -22,7 +24,7 @@ let package = Package( targets: [ .target( name: "ABSmartly", - dependencies: [.product(name: "Atomics", package: "swift-atomics"), "PromiseKit", "SwiftyJSON"], + dependencies: [.product(name: "Atomics", package: "swift-atomics"), "PromiseKit", "SwiftyJSON", "CircuitBreaker", .product(name: "SQLite", package: "SQLite.swift")], path: "Sources/ABSmartly"), .testTarget( name: "ABSmartlyTests", diff --git a/Sources/ABSmartly/ABSmartlyConfig.swift b/Sources/ABSmartly/ABSmartlyConfig.swift index 03902a0..96fd1f9 100644 --- a/Sources/ABSmartly/ABSmartlyConfig.swift +++ b/Sources/ABSmartly/ABSmartlyConfig.swift @@ -7,20 +7,29 @@ public class ABSmartlyConfig { var contextEventLogger: ContextEventLogger? var variableParser: VariableParser? var client: Client? + var resilienceConfig: ResilienceConfig? public init() { } public convenience init(client: Client) { + print("Hello, world!") self.init( contextDataProvider: nil, contextEventHandler: nil, contextEventLogger: nil, variableParser: nil, - scheduler: nil, client: client) + scheduler: nil, client: client, resilienceConfig: nil) + } + + public convenience init(client: Client, resilienceConfig: ResilienceConfig) { + print("Hello, world!") + self.init( + contextDataProvider: nil, contextEventHandler: nil, contextEventLogger: nil, variableParser: nil, + scheduler: nil, client: client, resilienceConfig: resilienceConfig) } public init( contextDataProvider: ContextDataProvider?, contextEventHandler: ContextEventHandler?, contextEventLogger: ContextEventLogger?, - variableParser: VariableParser?, scheduler: Scheduler?, client: Client? + variableParser: VariableParser?, scheduler: Scheduler?, client: Client?, resilienceConfig: ResilienceConfig? ) { self.scheduler = scheduler self.contextDataProvider = contextDataProvider @@ -28,5 +37,6 @@ public class ABSmartlyConfig { self.contextEventLogger = contextEventLogger self.variableParser = variableParser self.client = client + self.resilienceConfig = resilienceConfig } } diff --git a/Sources/ABSmartly/ABSmartlySDK.swift b/Sources/ABSmartly/ABSmartlySDK.swift index e0cde94..b929fe6 100644 --- a/Sources/ABSmartly/ABSmartlySDK.swift +++ b/Sources/ABSmartly/ABSmartlySDK.swift @@ -15,17 +15,29 @@ public final class ABSmartlySDK { scheduler = config.scheduler ?? DefaultScheduler() client = config.client - if config.contextDataProvider == nil || config.contextEventHandler == nil { - if client == nil { - throw ABSmartlyError("Missing Client instance") - } - - contextDataProvider = config.contextDataProvider ?? DefaultContextDataProvider(client: client!) - contextEventHandler = config.contextEventHandler ?? DefaultContextEventHandler(client: client!) + if config.resilienceConfig != nil { + contextEventHandler = ResilientContextEventHandler( + client: client!, + resilienceConfig: config.resilienceConfig! + ) + contextDataProvider = ResilientContextDataProvider( + client: client!, + localCache: config.resilienceConfig!.localCache + ) } else { - contextDataProvider = config.contextDataProvider! - contextEventHandler = config.contextEventHandler! + if config.contextDataProvider == nil || config.contextEventHandler == nil { + if client == nil { + throw ABSmartlyError("Missing Client instance") + } + + contextDataProvider = config.contextDataProvider ?? DefaultContextDataProvider(client: client!) + contextEventHandler = config.contextEventHandler ?? DefaultContextEventHandler(client: client!) + } else { + contextDataProvider = config.contextDataProvider! + contextEventHandler = config.contextEventHandler! + } } + } public func createContextWithData(config: ContextConfig, contextData: ContextData) -> Context { diff --git a/Sources/ABSmartly/Attribute.swift b/Sources/ABSmartly/Attribute.swift index e3078ba..e5d3980 100644 --- a/Sources/ABSmartly/Attribute.swift +++ b/Sources/ABSmartly/Attribute.swift @@ -1,6 +1,6 @@ import Foundation -public struct Attribute: Encodable, Equatable { +public struct Attribute: Encodable, Equatable, Decodable { public let name: String public let value: JSON diff --git a/Sources/ABSmartly/CircuitBreakerHelper.swift b/Sources/ABSmartly/CircuitBreakerHelper.swift new file mode 100644 index 0000000..fed7d69 --- /dev/null +++ b/Sources/ABSmartly/CircuitBreakerHelper.swift @@ -0,0 +1,116 @@ +// +// Created by Hermes Waldemarin on 14/03/2023. +// + +import CircuitBreaker +import Foundation +import PromiseKit + +public class CircuitBreakerHelper { + + private var circuitBreaker: CircuitBreaker, Resolver>! + private let scheduler: Scheduler = DefaultScheduler() + private let timeoutLock = NSLock() + private let flushLock = NSLock() + private var flushInExecution = false + private var timeout: ScheduledHandle? + private var backoffPeriodInMilliseconds: Int? + private var handler: ContextEventHandler? + + public init(resilienceConfig: ResilienceConfig, handler: ContextEventHandler) { + self.backoffPeriodInMilliseconds = resilienceConfig.backoffPeriodInMilliseconds + self.circuitBreaker = CircuitBreaker( + name: "Circuit1", + timeout: resilienceConfig.timeoutInMilliseconds, + maxFailures: resilienceConfig.failureRateThreshold, + command: callFunction, + fallback: fallback) + self.handler = handler + } + + public func decorate(promise: Promise, fallBackResolver: Resolver) -> Promise { + return Promise { seal in + circuitBreaker.run(commandArgs: promise, fallbackArgs: fallBackResolver) + seal.fulfill(()) + } + } + + func callFunction(invocation: Invocation, Resolver>) { + let promise = invocation.commandArgs + let fallBackResolver = invocation.fallbackArgs + + promise.done { response in + fallBackResolver.fulfill(nil) + var state = self.circuitBreaker.breakerState + invocation.notifySuccess() + if (state == .halfopen) && !self.flushInExecution { + self.flushInExecution = true + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self.timeoutLock.lock() + defer { self.timeoutLock.unlock() } + self.handler?.flushCache() + self.flushInExecution = false + } + } + + }.catch { error in + var key = "" + var message = "" + if error is LocalizedError { + let localizedError = error as! LocalizedError + key = localizedError._domain + message = localizedError.errorDescription ?? "" + } else { + let nsError = error as! NSError + key = error._domain + nsError.code + message = error.localizedDescription + } + + invocation.notifyFailure( + error: BreakerError( + key: key, + reason: message + ) + ) + } + } + + private func fallback(err: BreakerError, fallBackPromise: Resolver) { + fallBackPromise.fulfill(err) + if err == .fastFail { + setTimeout() + } else { + } + } + + private func clearTimeout() { + if timeout != nil { + timeoutLock.lock() + defer { timeoutLock.unlock() } + + timeout?.cancel() + timeout = nil + } + } + + private func setTimeout() { + if timeout == nil { + timeoutLock.lock() + defer { timeoutLock.unlock() } + + if timeout == nil { + timeout = scheduler.schedule( + after: Double((backoffPeriodInMilliseconds! / 1000)), + execute: { [self] in + clearTimeout() + if circuitBreaker.breakerState != State.closed { + print("Resilience entering in half open state") + circuitBreaker.forceHalfOpen() + } + + }) + } + } + } +} diff --git a/Sources/ABSmartly/Context.swift b/Sources/ABSmartly/Context.swift index d2976aa..e254925 100644 --- a/Sources/ABSmartly/Context.swift +++ b/Sources/ABSmartly/Context.swift @@ -137,6 +137,7 @@ public final class Context { seal.fulfill(self) } else if let ready = readyPromise { _ = ready.done { + self.handler.flushCache() seal.fulfill(self) } } @@ -263,7 +264,7 @@ public final class Context { } public func getAttributes() -> [String: JSON] { - var result: [String:JSON] = [:] + var result: [String: JSON] = [:] contextLock.lock() defer { contextLock.unlock() } @@ -271,7 +272,7 @@ public final class Context { for attribute in attributes { result[attribute.name] = attribute.value } - return result; + return result } public func setAttributes(_ attributes: [String: JSON]) { diff --git a/Sources/ABSmartly/DefaultContextEventHandler.swift b/Sources/ABSmartly/DefaultContextEventHandler.swift index 251da2d..af235e5 100644 --- a/Sources/ABSmartly/DefaultContextEventHandler.swift +++ b/Sources/ABSmartly/DefaultContextEventHandler.swift @@ -11,4 +11,8 @@ public class DefaultContextEventHandler: ContextEventHandler { public func publish(event: PublishEvent) -> Promise { return client.publish(event: event) } + + public func flushCache() { + + } } diff --git a/Sources/ABSmartly/Exposure.swift b/Sources/ABSmartly/Exposure.swift index 1d28a47..7555f6f 100644 --- a/Sources/ABSmartly/Exposure.swift +++ b/Sources/ABSmartly/Exposure.swift @@ -1,6 +1,6 @@ import Foundation -public struct Exposure: Encodable, Equatable { +public struct Exposure: Encodable, Equatable, Decodable { public let id: Int public let name: String public let unit: String? diff --git a/Sources/ABSmartly/GoalAchievement.swift b/Sources/ABSmartly/GoalAchievement.swift index 7aa8bdd..aacfe62 100644 --- a/Sources/ABSmartly/GoalAchievement.swift +++ b/Sources/ABSmartly/GoalAchievement.swift @@ -1,6 +1,6 @@ import Foundation -public struct GoalAchievement: Encodable, Equatable { +public struct GoalAchievement: Encodable, Equatable, Decodable { public let name: String public let achievedAt: Int64 public let properties: [String: JSON]? diff --git a/Sources/ABSmartly/MemoryCache.swift b/Sources/ABSmartly/MemoryCache.swift new file mode 100644 index 0000000..f03812a --- /dev/null +++ b/Sources/ABSmartly/MemoryCache.swift @@ -0,0 +1,25 @@ +// +// Created by Hermes Waldemarin on 09/03/2023. +// + +import Foundation +import PromiseKit +import SQLite + +public class MemoryCache: SqlliteCache { + public override init() { + } + + public override func getConnection() -> Connection { + do { + if db == nil { + db = try! Connection(.inMemory) + setupDatabase() + } + } catch { + print(error) + } + return self.db! + } + +} diff --git a/Sources/ABSmartly/Protocols/ContextEventHandler.swift b/Sources/ABSmartly/Protocols/ContextEventHandler.swift index c263ec6..8edb2ed 100644 --- a/Sources/ABSmartly/Protocols/ContextEventHandler.swift +++ b/Sources/ABSmartly/Protocols/ContextEventHandler.swift @@ -4,4 +4,5 @@ import PromiseKit // sourcery: AutoMockable public protocol ContextEventHandler { func publish(event: PublishEvent) -> Promise + func flushCache() } diff --git a/Sources/ABSmartly/Protocols/LocalCache.swift b/Sources/ABSmartly/Protocols/LocalCache.swift new file mode 100644 index 0000000..9d48290 --- /dev/null +++ b/Sources/ABSmartly/Protocols/LocalCache.swift @@ -0,0 +1,13 @@ +// +// Created by Hermes Waldemarin on 09/03/2023. +// + +import Foundation +import PromiseKit + +public protocol LocalCache { + func writePublishEvent(event: PublishEvent) + func retrievePublishEvents() -> [PublishEvent] + func writeContextData(contextData: ContextData) + func getContextData() -> ContextData? +} diff --git a/Sources/ABSmartly/PublishEvent.swift b/Sources/ABSmartly/PublishEvent.swift index 17957c7..eb7c17c 100644 --- a/Sources/ABSmartly/PublishEvent.swift +++ b/Sources/ABSmartly/PublishEvent.swift @@ -1,6 +1,6 @@ import Foundation -public final class PublishEvent: Encodable, Equatable { +public final class PublishEvent: Encodable, Equatable, Decodable { public var hashed: Bool public var units: [Unit] public var publishedAt: Int64 @@ -52,6 +52,22 @@ public final class PublishEvent: Encodable, Equatable { } } + public init(from decoder: Decoder) throws { + guard let container = try? decoder.container(keyedBy: CodingKeys.self) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: [], debugDescription: "PublishEvent couldn't be decoded from this data") + ) + } + + units = (try? container.decode([Unit].self, forKey: .units)) ?? [] + exposures = (try? container.decode([Exposure].self, forKey: .exposures)) ?? [] + goals = (try? container.decode([GoalAchievement].self, forKey: .goals)) ?? [] + attributes = (try? container.decode([Attribute].self, forKey: .attributes)) ?? [] + hashed = (try? container.decodeIfPresent(Bool.self, forKey: .hashed)) ?? false + publishedAt = (try? container.decodeIfPresent(Int64.self, forKey: .publishedAt)) ?? 0 + } + public static func == (lhs: PublishEvent, rhs: PublishEvent) -> Bool { return lhs.hashed == rhs.hashed && lhs.units == rhs.units && lhs.publishedAt == rhs.publishedAt && lhs.exposures == rhs.exposures && lhs.goals == rhs.goals && lhs.attributes == rhs.attributes diff --git a/Sources/ABSmartly/ResilienceConfig.swift b/Sources/ABSmartly/ResilienceConfig.swift new file mode 100644 index 0000000..034d969 --- /dev/null +++ b/Sources/ABSmartly/ResilienceConfig.swift @@ -0,0 +1,41 @@ +import Foundation + +public class ResilienceConfig { + public var failureRateThreshold: Int = 20 + public var backoffPeriodInMilliseconds: Int = 30000 + public var timeoutInMilliseconds: Int = 30000 + public var localCache: LocalCache + + public init(localCache: LocalCache) { + self.localCache = localCache + } + + public init( + failureRateThreshold: Int, backoffPeriodInMilliseconds: Int, timeoutInMilliseconds: Int, localCache: LocalCache + ) { + self.failureRateThreshold = failureRateThreshold + self.backoffPeriodInMilliseconds = backoffPeriodInMilliseconds + self.timeoutInMilliseconds = timeoutInMilliseconds + self.localCache = localCache + } + + public convenience init(from data: Data) { + let dict = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: String] + self.init(from: dict ?? [:]) + } + + public convenience init(from dict: [String: String]) { + var localCacheImpl: LocalCache = NSClassFromString(dict["localCacheImplClass"] ?? "SqlliteCache") as! LocalCache + + if let localCacheImplClass = localCacheImpl as? LocalCache.Type { + self.init( + failureRateThreshold: dict["failureRateThreshold"] as! Int, + backoffPeriodInMilliseconds: dict["backoffPeriodInMilliseconds"] as! Int, + timeoutInMilliseconds: dict["timeoutInMilliseconds"] as! Int, + localCache: localCacheImpl) + } else { + fatalError("LocalCache is not a LocalCache subtype") + } + + } +} diff --git a/Sources/ABSmartly/ResilientContextDataProvider.swift b/Sources/ABSmartly/ResilientContextDataProvider.swift new file mode 100644 index 0000000..6459f4c --- /dev/null +++ b/Sources/ABSmartly/ResilientContextDataProvider.swift @@ -0,0 +1,30 @@ +import Foundation +import PromiseKit + +public class ResilientContextDataProvider: ContextDataProvider { + private let client: Client + private let localCache: LocalCache + + public init(client: Client, localCache: LocalCache) { + self.client = client + self.localCache = localCache + } + + public func getContextData() -> Promise { + let (promiseReturn, resolver) = Promise.pending() + var promise = client.getContextData() + + promise.done { contextData in + self.localCache.writeContextData(contextData: contextData) + resolver.fulfill(contextData) + }.catch { error in + var contextData = self.localCache.getContextData() + if contextData != nil { + resolver.fulfill(contextData!) + } else { + resolver.reject(error) + } + } + return promiseReturn + } +} diff --git a/Sources/ABSmartly/ResilientContextEventHandler.swift b/Sources/ABSmartly/ResilientContextEventHandler.swift new file mode 100644 index 0000000..7067365 --- /dev/null +++ b/Sources/ABSmartly/ResilientContextEventHandler.swift @@ -0,0 +1,37 @@ +import CircuitBreaker +import Foundation +import PromiseKit + +public class ResilientContextEventHandler: ContextEventHandler { + private let client: Client + private let localCache: LocalCache + private var circuitBreaker: CircuitBreakerHelper! + private let clock: DefaultClock = DefaultClock() + + public init(client: Client, resilienceConfig: ResilienceConfig) { + self.client = client + self.localCache = resilienceConfig.localCache + self.circuitBreaker = CircuitBreakerHelper(resilienceConfig: resilienceConfig, handler: self) + } + + public func flushCache() { + var events = localCache.retrievePublishEvents() + print("Sending events in cache: \(events.count)") + for event in events { + event.publishedAt = clock.millis() + self.publish(event: event) + } + } + + public func publish(event: PublishEvent) -> Promise { + let (fallbackPromise, fallBackResolver) = Promise.pending() + + fallbackPromise.done { err in + if err != nil { + self.localCache.writePublishEvent(event: event) + } + } + + return circuitBreaker.decorate(promise: client.publish(event: event), fallBackResolver: fallBackResolver) + } +} diff --git a/Sources/ABSmartly/SqlliteCache.swift b/Sources/ABSmartly/SqlliteCache.swift new file mode 100644 index 0000000..a39736a --- /dev/null +++ b/Sources/ABSmartly/SqlliteCache.swift @@ -0,0 +1,102 @@ +// +// Created by Hermes Waldemarin on 09/03/2023. +// + +import Foundation +import PromiseKit +import SQLite + +public class SqlliteCache: LocalCache { + + let path = NSSearchPathForDirectoriesInDomains( + .documentDirectory, .userDomainMask, true) + + var db: Connection? + + public init() { + } + + public func getConnection() -> Connection { + do { + if db == nil { + db = try! Connection("\(path.first ?? "")/absmartly.sqlite3") + setupDatabase() + } + } catch { + Logger.error(error.localizedDescription) + } + return self.db! + } + + public func setupDatabase() { + do { + var stmt = try getConnection().prepare( + "create table if not exists events (id INTEGER PRIMARY KEY AUTOINCREMENT, event text)") + try stmt.run() + + stmt = try getConnection().prepare( + "create table if not exists context (id INTEGER PRIMARY KEY AUTOINCREMENT, context text)") + try stmt.run() + } catch { + Logger.error(error.localizedDescription) + } + } + + public func writePublishEvent(event: PublishEvent) { + do { + let stmt = try self.getConnection().prepare("insert into events (event) values (?)") + let eventStr = try JSONEncoder().encode(event) + let binding = String(bytes: eventStr, encoding: .utf8) + try stmt.run(binding) + } catch { + Logger.error(error.localizedDescription) + } + } + + public func retrievePublishEvents() -> [PublishEvent] { + var events: [PublishEvent] = [PublishEvent]() + do { + for row in try self.getConnection().prepare("select * from events") { + let dataString = row[1] as? String ?? "" + let data = dataString.data(using: .utf8) ?? Data() + let event = try JSONDecoder().decode(PublishEvent.self, from: data) + events.append(event) + } + let deleteStm = try self.getConnection().prepare("delete from events") + try deleteStm.run() + } catch { + Logger.error(error.localizedDescription) + } + return events + } + + public func writeContextData(contextData: ContextData) { + do { + let deleteStm = try self.getConnection().prepare("delete from context") + try deleteStm.run() + + let stmt = try getConnection().prepare("insert into context (context) values (?)") + let data = try JSONEncoder().encode(contextData) + let binding = String(data: data, encoding: .utf8) + try stmt.run(binding) + } catch { + Logger.error(error.localizedDescription) + } + } + + public func getContextData() -> ContextData? { + var contextData: ContextData? + do { + for row in try self.getConnection().prepare("select * from context") { + let dataString = row[1] as? String ?? "" + let data = dataString.data(using: .utf8) ?? Data() + let ctx = try JSONDecoder().decode(ContextData.self, from: data) + contextData = ctx + } + } catch { + Logger.error(error.localizedDescription) + } + return contextData + } + +} diff --git a/Templates/AutoMockable.stencil b/Templates/AutoMockable.stencil index 7e11805..39d340a 100755 --- a/Templates/AutoMockable.stencil +++ b/Templates/AutoMockable.stencil @@ -10,6 +10,7 @@ import {{ import }} @testable import {{ import }} {% endfor %} + {% macro swiftifyMethodName name %}{{ name | replace:"(","_" | replace:")","" | replace:":","_" | replace:"`","" | snakeToCamelCase | lowerFirstWord }}{% endmacro %} {% macro methodThrowableErrorDeclaration method %} diff --git a/Tests/ABSmartlyTests/Cache/MemoryCacheTest.swift b/Tests/ABSmartlyTests/Cache/MemoryCacheTest.swift new file mode 100644 index 0000000..a3845a4 --- /dev/null +++ b/Tests/ABSmartlyTests/Cache/MemoryCacheTest.swift @@ -0,0 +1,90 @@ +import Foundation +import PromiseKit +import XCTest + +@testable import ABSmartly + +final class MemoryCacheTest: XCTestCase { + var localCache: MemoryCache? + + override func setUp() { + localCache = MemoryCache() + } + + func testWriteEventsToCache() { + let expectation = XCTestExpectation() + let expectation2 = XCTestExpectation() + + guard let localCache: MemoryCache = localCache else { return } + + let units = [ + Unit(type: "session_id", uid: "pAE3a1i5Drs5mKRNq56adA"), + Unit(type: "user_id", uid: "JfnnlDI7RTiF9RgfG2JNCw"), + ] + + let attributes = [ + Attribute("attr1", value: "value1", setAt: 123_456_000), + Attribute("attr2", value: "value2", setAt: 123_456_789), + Attribute("attr2", value: JSON.null, setAt: 123_450_000), + Attribute("attr3", value: ["nested": ["value": 5]], setAt: 123_470_000), + Attribute("attr4", value: ["nested": [1, 2, "test"]], setAt: 123_480_000), + ] + + let exposures = [ + Exposure(1, "exp_test_ab", "session_id", 1, 123_470_000, true, true, false, false, false, true) + ] + + let propertiesMap: [String: JSON] = ["amount": 6, "value": 5.25, "tries": 1] + + let goals = [ + GoalAchievement("goal1", achievedAt: 123_456_000, properties: propertiesMap), + GoalAchievement("goal2", achievedAt: 123_456_789, properties: nil), + ] + + let event = PublishEvent(true, units, 123_456_789, exposures, goals, attributes) + + do { + let result = localCache.writePublishEvent(event: event) + expectation.fulfill() + } catch { + XCTFail(error.localizedDescription) + } + + do { + let events = localCache.retrievePublishEvents() + XCTAssertTrue(events.count == 1) + expectation2.fulfill() + } catch { + XCTFail(error.localizedDescription) + } + + wait(for: [expectation, expectation2], timeout: 5.0) + + } + + func testWriteContextDataToCache() throws { + let expectation = XCTestExpectation() + let expectation2 = XCTestExpectation() + + guard let localCache = localCache else { return } + + let contextData = ContextData(experiments: [Experiment]()) + + do { + let writeResult = localCache.writeContextData(contextData: contextData) + expectation.fulfill() + } catch { + XCTFail(error.localizedDescription) + } + + do { + let contextDateSaved = localCache.getContextData() + XCTAssertEqual(contextData, contextDateSaved) + expectation2.fulfill() + } catch { + XCTFail(error.localizedDescription) + } + wait(for: [expectation, expectation2], timeout: 5.0) + } + +} diff --git a/Tests/ABSmartlyTests/Cache/SqlliteCacheTest.swift b/Tests/ABSmartlyTests/Cache/SqlliteCacheTest.swift new file mode 100644 index 0000000..27b2bb8 --- /dev/null +++ b/Tests/ABSmartlyTests/Cache/SqlliteCacheTest.swift @@ -0,0 +1,91 @@ +import Foundation +import PromiseKit +import XCTest + +@testable import ABSmartly + +final class SqlliteCacheTest: XCTestCase { + var localCache: SqlliteCache? + + override func setUp() { + localCache = SqlliteCache() + } + + func testWriteEventsToCache() { + let expectation = XCTestExpectation() + let expectation2 = XCTestExpectation() + + guard let localCache = localCache else { return } + + let units = [ + Unit(type: "session_id", uid: "pAE3a1i5Drs5mKRNq56adA"), + Unit(type: "user_id", uid: "JfnnlDI7RTiF9RgfG2JNCw"), + ] + + let attributes = [ + Attribute("attr1", value: "value1", setAt: 123_456_000), + Attribute("attr2", value: "value2", setAt: 123_456_789), + Attribute("attr2", value: JSON.null, setAt: 123_450_000), + Attribute("attr3", value: ["nested": ["value": 5]], setAt: 123_470_000), + Attribute("attr4", value: ["nested": [1, 2, "test"]], setAt: 123_480_000), + ] + + let exposures = [ + Exposure(1, "exp_test_ab", "session_id", 1, 123_470_000, true, true, false, false, false, true) + ] + + let propertiesMap: [String: JSON] = ["amount": 6, "value": 5.25, "tries": 1] + + let goals = [ + GoalAchievement("goal1", achievedAt: 123_456_000, properties: propertiesMap), + GoalAchievement("goal2", achievedAt: 123_456_789, properties: nil), + ] + + let event = PublishEvent(true, units, 123_456_789, exposures, goals, attributes) + + do { + let result = localCache.writePublishEvent(event: event) + expectation.fulfill() + } catch { + XCTFail(error.localizedDescription) + } + + do { + let events = localCache.retrievePublishEvents() + XCTAssertTrue(events.count == 1) + expectation2.fulfill() + } catch { + XCTFail(error.localizedDescription) + } + + wait(for: [expectation, expectation2], timeout: 5.0) + + } + + func testWriteContextDataToCache() throws { + let expectation = XCTestExpectation() + let expectation2 = XCTestExpectation() + + guard let localCache = localCache else { return } + + let contextData = ContextData(experiments: [Experiment]()) + + do { + let writeResult = localCache.writeContextData(contextData: contextData) + expectation.fulfill() + } catch { + XCTFail(error.localizedDescription) + } + + do { + let contextDateSaved = localCache.getContextData() + XCTAssertEqual(contextData, contextDateSaved) + expectation2.fulfill() + } catch { + XCTFail(error.localizedDescription) + } + + wait(for: [expectation, expectation2], timeout: 5.0) + } + +} diff --git a/Tests/ABSmartlyTests/CircuitBreaker/CircuitBreakerHelperTest.swift b/Tests/ABSmartlyTests/CircuitBreaker/CircuitBreakerHelperTest.swift new file mode 100644 index 0000000..5f7bf8d --- /dev/null +++ b/Tests/ABSmartlyTests/CircuitBreaker/CircuitBreakerHelperTest.swift @@ -0,0 +1,183 @@ +import CircuitBreaker +import Foundation +import PromiseKit +import XCTest + +@testable import ABSmartly + +final class CircuitBreakerHandlerTest: XCTestCase { + private var errorInOpenState: Int = 0 + private var errorInCall: Int = 0 + private var successCount: Int = 0 + + var helper: CircuitBreakerHelper! = nil + + func testPromises() async { + let expectation = XCTestExpectation() + print("Linha 1") + var promise = Promise { seal in + print("external promise executed 1") + var prom = Promise { seal2 in + print("internal promise executed 1") + usleep(useconds_t(2000 * 1000)) + print("internal promise executed 2") + seal2.fulfill("Test Result") + } + print("external promise executed 2") + //expectation.fulfill() + prom.get { s in + usleep(useconds_t(3000 * 1000)) + print("external promise get executed") + seal.fulfill(s) + } + } + print("Linha 2") + promise = promise.then { result -> Promise in + print(result) + return Promise { seal3 in + seal3.fulfill("Test Result 2") + expectation.fulfill() + } + } + print("Linha 3") + promise.done { response in + print("finalizou o promise: " + response) + } + + print("Linha 4") + + wait(for: [expectation], timeout: 8.0) + print("terminou a classe") + } + + func testCircuitBreakerTimeout() async { + let expectation = XCTestExpectation() + + var resilienceConfig = ResilienceConfig(localCache: MemoryCache()) + resilienceConfig.timeoutInMilliseconds = 1000 + + var mockHandler = ContextEventHandlerMock() + + helper = CircuitBreakerHelper(resilienceConfig: resilienceConfig, handler: mockHandler) + print("Linha 1") + let (promise, resolver) = Promise.pending() + + promise.done { data in + print("main promise") + } + + print("Linha 2") + let (fallbackPromise, fallBackResolver) = Promise.pending() + + print("Linha 3") + fallbackPromise.done { data in + XCTAssertEqual("A timeout occurred.", data!.reason) + print("gravando no cache") + expectation.fulfill() + } + + var promise2 = helper.decorate(promise: promise, fallBackResolver: fallBackResolver) + + promise2.done { data in + print("promise2 finished") + + } + wait(for: [expectation], timeout: 8.0) + } + + func testCircuitBreakerError() async { + let expectation = XCTestExpectation() + + var resilienceConfig = ResilienceConfig(localCache: MemoryCache()) + + var mockHandler = ContextEventHandlerMock() + + helper = CircuitBreakerHelper(resilienceConfig: resilienceConfig, handler: mockHandler) + print("Linha 1") + let (promise, resolver) = Promise.pending() + + promise.done { data in + print("main promise") + expectation.fulfill() + } + + print("Linha 2") + let (fallbackPromise, fallBackResolver) = Promise.pending() + + print("Linha 3") + fallbackPromise.done { data in + XCTAssertEqual("General Error", data!.reason) + print("gravando no cache") + expectation.fulfill() + } + + var promise2 = helper.decorate(promise: promise, fallBackResolver: fallBackResolver) + + promise2.done { data in + print("promise2 finished") + } + + resolver.reject(ABSmartlyError("General Error")) + wait(for: [expectation], timeout: 8.0) + } + + func testCircuitBreakerFullProcess() async { + let expectation = XCTestExpectation() + + var resilienceConfig = ResilienceConfig(localCache: MemoryCache()) + resilienceConfig.backoffPeriodInMilliseconds = 1000 + + var mockHandler = ContextEventHandlerMock() + + helper = CircuitBreakerHelper(resilienceConfig: resilienceConfig, handler: mockHandler) + + var errorInOpenState: Int = 0 + var errorInCall: Int = 0 + var successCount: Int = 0 + + for (index) in 1...300 { + let (promise, resolver) = Promise.pending() + let (fallbackPromise, fallBackResolver) = Promise.pending() + let event = PublishEvent() + event.publishedAt = Int64(index) + fallbackPromise.done { err in + if err != nil { + if err == .fastFail { + errorInOpenState = errorInOpenState + 1 + usleep(useconds_t(Int.random(in: 0..<40) * 1000)) + + } else { + errorInCall = errorInCall + 1 + } + } else { + successCount = successCount + 1 + } + } + + var promise2 = helper.decorate(promise: promise, fallBackResolver: fallBackResolver) + + usleep(useconds_t(Int.random(in: 0..<200) * 1000)) + + if index > 100 && index < 200 { + resolver.reject(ABSmartlyError("General Error")) + } else { + resolver.fulfill(()) + } + + } + + usleep(useconds_t(2000 * 1000)) + + print("errorInOpenState: \(errorInOpenState)") + print("errorInCall: \(errorInCall)") + print("successCount: \(successCount)") + XCTAssertEqual(1, mockHandler.flushCacheCallsCount) + XCTAssertTrue(errorInOpenState > 0) + XCTAssertTrue(errorInCall > 0) + XCTAssertTrue(successCount > 0) + XCTAssertTrue(errorInOpenState > errorInCall) + XCTAssertEqual(errorInOpenState + errorInCall + successCount, 300) + + } + +} diff --git a/Tests/ABSmartlyTests/CircuitBreaker/CircuitBreakerLibraryTest.swift b/Tests/ABSmartlyTests/CircuitBreaker/CircuitBreakerLibraryTest.swift new file mode 100644 index 0000000..39c3b19 --- /dev/null +++ b/Tests/ABSmartlyTests/CircuitBreaker/CircuitBreakerLibraryTest.swift @@ -0,0 +1,106 @@ +import CircuitBreaker +import Foundation +import PromiseKit +import XCTest + +@testable import ABSmartly + +extension BreakerError { + public static let encodingURLError = BreakerError(reason: "URL could not be created") + public static let networkingError = BreakerError(reason: "There was an error, while sending the request") + public static let jsonDecodingError = BreakerError(reason: "Could not decode result into JSON") +} + +final class CircuitBreakerLibraryTest: XCTestCase { + private let scheduler: Scheduler = DefaultScheduler() + private let timeoutLock = NSLock() + private var timeout: ScheduledHandle? + private var errorInOpenState: Int = 0 + private var errorInCall: Int = 0 + private var successCount: Int = 0 + + var breaker: CircuitBreaker<[Int], String>! = nil + func testCircuitBreaker() async { + breaker = CircuitBreaker(name: "Circuit1", command: myContextFunction, fallback: myFallback) + + let requestParam: String = "myRequestParams" + + for (index) in 1...300 { + let randomInt = Int.random(in: 0..<200) + + //print("Active after \(randomInt)") + breaker.run(commandArgs: [index, randomInt], fallbackArgs: "Something went wrong.") + } + + print("errorInOpenState \(errorInOpenState)") + print("errorInCall \(errorInCall)") + print("successCount \(successCount)") + XCTAssertTrue(errorInOpenState > 0) + XCTAssertTrue(errorInCall > 0) + XCTAssertTrue(successCount > 0) + XCTAssertTrue(errorInOpenState > errorInCall) + XCTAssertEqual(errorInOpenState + errorInCall + successCount, 300) + + } + + func myContextFunction(invocation: Invocation<([Int]), String>) { + let requestParam = invocation.commandArgs[0] + let randomInt = invocation.commandArgs[1] + // Create HTTP request + let ms = 1000 + usleep(useconds_t(randomInt * ms)) + if requestParam > 100 && requestParam < 200 { + invocation.notifyFailure(error: .encodingURLError) + } else { + successCount = successCount + 1 + invocation.notifySuccess() + } + } + + private func clearTimeout() { + if timeout != nil { + timeoutLock.lock() + defer { timeoutLock.unlock() } + + timeout?.cancel() + timeout = nil + } + } + + private func setTimeout() { + if timeout == nil { + timeoutLock.lock() + defer { timeoutLock.unlock() } + + if timeout == nil { + print("setTimeout") + timeout = scheduler.schedule( + after: 4, + execute: { [self] in + clearTimeout() + if breaker.breakerState != State.closed { + print("Resilience entering entering in half open state") + breaker.forceHalfOpen() + } + + }) + } + } + } + + func myFallback(err: BreakerError, msg: String) { + if err == .fastFail { + let randomInt = Int.random(in: 0..<200) + let ms = 1000 + usleep(useconds_t(randomInt * ms)) + errorInOpenState = errorInOpenState + 1 + setTimeout() + } else { + errorInCall = errorInCall + 1 + print("Error: \(err.reason)") + print("Message: \(msg)") + } + + //XCTFail(msg) + } +} diff --git a/Tests/ABSmartlyTests/CircuitBreaker/ResilienceTest.swift b/Tests/ABSmartlyTests/CircuitBreaker/ResilienceTest.swift new file mode 100644 index 0000000..3816874 --- /dev/null +++ b/Tests/ABSmartlyTests/CircuitBreaker/ResilienceTest.swift @@ -0,0 +1,69 @@ +import CircuitBreaker +import Foundation +import PromiseKit +import XCTest + +@testable import ABSmartly + +final class ResilienceTest: XCTestCase { + private var sdk: ABSmartlySDK! + private var context: Context! + func fullExecution() async { + let expectation = XCTestExpectation() + setupSDK() + for (index) in 1...2000 { + usleep(useconds_t(50 * 1000)) + var promise = Promise { seal in + context.track("payment", properties: ["amount": 2235, "revenue": 235]) + seal.fulfill(()) + } + promise.done { data in + + } + } + wait(for: [expectation], timeout: 200.0) + } + + func setupSDK() { + do { + + let clientConfig = ClientConfig( + // The following environment variables should be changed in: + // Product -> Scheme -> Edit Scheme -> Run -> Arguments + apiKey: ProcessInfo.processInfo.environment["ABSMARTLY_API_KEY"] + ?? "API_KEY", + application: ProcessInfo.processInfo.environment["ABSMARTLY_APPLICATION"] ?? "web", + endpoint: ProcessInfo.processInfo.environment["ABSMARTLY_ENDPOINT"] ?? "ENDPOINT", + environment: ProcessInfo.processInfo.environment["ABSMARTLY_ENVIRONMENT"] ?? "prod") + + let client = try DefaultClient(config: clientConfig) + + let localCache: LocalCache = SqlliteCache() + let resilienceConfig = ResilienceConfig(localCache: localCache) + resilienceConfig.backoffPeriodInMilliseconds = 3000 + let sdkConfig = ABSmartlyConfig(client: client, resilienceConfig: resilienceConfig) + //let sdkConfig = ABSmartlyConfig(client: client) + sdk = try ABSmartlySDK(config: sdkConfig) + let contextConfig = ContextConfig() + contextConfig.refreshInterval = 5 + contextConfig.setUnit( + unitType: "user_id", uid: "123456") + context = sdk.createContext(config: contextConfig) + _ = context.waitUntilReady().done { context in + let treatment = context.getTreatment("Experimentationless") + + DispatchQueue.main.async { + if treatment == 0 { + //self. = .blue + } else { + //self.backgroundColor = .orange + } + + } + } + } catch { + print(error.localizedDescription) + } + } + +} diff --git a/Tests/ABSmartlyTests/ContextTest.swift b/Tests/ABSmartlyTests/ContextTest.swift index b430afc..0fdb121 100644 --- a/Tests/ABSmartlyTests/ContextTest.swift +++ b/Tests/ABSmartlyTests/ContextTest.swift @@ -341,7 +341,9 @@ final class ContextTest: XCTestCase { context.setUnits(["session_id": "0ab1e23f4eee", "user_id": "1234567890"]) - XCTAssertEqual(["session_id": "0ab1e23f4eee", "user_id": "1234567890", "anonymous_id": "0ab1e-23f4-feee"], context.getUnits()) + XCTAssertEqual( + ["session_id": "0ab1e23f4eee", "user_id": "1234567890", "anonymous_id": "0ab1e-23f4-feee"], + context.getUnits()) } func testSetUnitsBeforeReady() throws { @@ -410,7 +412,7 @@ final class ContextTest: XCTestCase { XCTAssertFalse(context.isFailed()) context.setAttribute(name: "attr1", value: "value1") context.setAttributes(["attr2": "value2"]) - XCTAssertEqual(["attr1":"value1", "attr2": "value2"], context.getAttributes()) + XCTAssertEqual(["attr1": "value1", "attr2": "value2"], context.getAttributes()) resolver.fulfill(try getContextData()) } diff --git a/Tests/ABSmartlyTests/Mocks/SourceryGenerated.swift b/Tests/ABSmartlyTests/Mocks/SourceryGenerated.swift index 922ae44..77e8800 100644 --- a/Tests/ABSmartlyTests/Mocks/SourceryGenerated.swift +++ b/Tests/ABSmartlyTests/Mocks/SourceryGenerated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.7.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.0.1 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT // swiftlint:disable line_length @@ -152,10 +152,24 @@ class ContextEventHandlerMock: ContextEventHandler { } } + //MARK: - flushCache + + var flushCacheCallsCount = 0 + var flushCacheCalled: Bool { + return flushCacheCallsCount > 0 + } + var flushCacheClosure: (() -> Void)? + + func flushCache() { + flushCacheCallsCount += 1 + flushCacheClosure?() + } + func clearInvocations() { publishEventCallsCount = 0 publishEventReceivedEvent = nil publishEventReceivedInvocations = [] + flushCacheCallsCount = 0 } } diff --git a/Tests/ABSmartlyTests/ResilientContextDataProviderTest.swift b/Tests/ABSmartlyTests/ResilientContextDataProviderTest.swift new file mode 100644 index 0000000..d40cd03 --- /dev/null +++ b/Tests/ABSmartlyTests/ResilientContextDataProviderTest.swift @@ -0,0 +1,64 @@ +import CircuitBreaker +import Foundation +import PromiseKit +import XCTest + +@testable import ABSmartly + +final class ResilientContextDataProviderTest: XCTestCase { + + var contextData: ContextData? + + func getContextDataOK() -> Promise { + return Promise { seal in + seal.fulfill(self.contextData!) + } + } + + func getContextData(source: String = "context") throws -> ContextData { + let path = Bundle.module.path(forResource: source, ofType: "json", inDirectory: "Resources")! + let data = try Foundation.Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) + return try JSONDecoder().decode(ContextData.self, from: data) + } + + func getContextDataError() -> Promise { + let (promiseReturn, resolver) = Promise.pending() + resolver.reject(ABSmartlyError("Error")) + return promiseReturn + } + + func testResilience() async throws { + let expectation = XCTestExpectation() + + contextData = try getContextData() + + let mockClient = ClientMock() + mockClient.getContextDataClosure = getContextDataOK + let memoryCache = MemoryCache() + let contextProvider = ResilientContextDataProvider( + client: mockClient, + localCache: memoryCache + ) + + var promise1 = contextProvider.getContextData() + promise1.done { data in + XCTAssertEqual(self.contextData!.experiments.count, data.experiments.count) + } + + let mockClient2 = ClientMock() + mockClient2.getContextDataClosure = getContextDataError + let contextProvider2 = ResilientContextDataProvider( + client: mockClient2, + localCache: memoryCache + ) + var promise2 = contextProvider2.getContextData() + promise2.done { data in + expectation.fulfill() + XCTAssertEqual(self.contextData!.experiments.count, data.experiments.count) + } + + wait(for: [expectation], timeout: 8.0) + + } + +}