From 4e9d749fe0fa61624b851649b8531c0a5d4c0578 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Mon, 16 Feb 2026 10:10:43 -0500 Subject: [PATCH 01/16] Added auth to RT and Optistream components --- .../Sources/Classes/Auth/AuthManager.swift | 58 ++++++++ .../Optistream/OptistreamDispatcher.swift | 128 ++++++++++++++++++ .../Optistream/OptistreamNetworking.swift | 36 ++--- .../Components/OptiTrack/OptiTrack.swift | 50 ++++--- .../Components/RealTime/RealTime.swift | 32 +++-- .../Classes/Factories/ComponentFactory.swift | 31 +++-- OptimoveSDK/Sources/Classes/Optimove.swift | 13 +- .../Sources/Classes/OptimoveConfig.swift | 21 ++- .../Classes/Services/ServiceLocator.swift | 6 +- 9 files changed, 301 insertions(+), 74 deletions(-) create mode 100644 OptimoveCore/Sources/Classes/Auth/AuthManager.swift create mode 100644 OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift diff --git a/OptimoveCore/Sources/Classes/Auth/AuthManager.swift b/OptimoveCore/Sources/Classes/Auth/AuthManager.swift new file mode 100644 index 00000000..3d924e90 --- /dev/null +++ b/OptimoveCore/Sources/Classes/Auth/AuthManager.swift @@ -0,0 +1,58 @@ +// Copyright © 2026 Optimove. All rights reserved. + +import Foundation + +/// Closure type that the client app provides to supply JWTs for authenticated requests. +/// - Parameters: +/// - userId: The user identifier to fetch a token for. +/// - completion: Call with `(token, nil)` on success or `(nil, error)` on failure. +public typealias AuthTokenProvider = ( + _ userId: String, + _ completion: @escaping (_ token: String?, _ error: Error?) -> Void +) -> Void + +/// Errors related to auth token fetching. +public enum AuthError: Error, LocalizedError { + case tokenFetchFailed + case noUserId + + public var errorDescription: String? { + switch self { + case .tokenFetchFailed: + return "Failed to fetch auth token from provider." + case .noUserId: + return "No userId available for auth token request." + } + } +} + +/// Manages JWT token retrieval from the client-provided closure. +/// - Threading: `getToken` can be called from any queue. The `completion` callback is invoked +/// on whatever queue the client's `AuthTokenProvider` closure dispatches to — callers should +/// not assume any specific queue and should dispatch to their target queue if needed. +public final class AuthManager { + private let provider: AuthTokenProvider + + public init(provider: @escaping AuthTokenProvider) { + self.provider = provider + } + + /// Request a JWT for the given userId from the client-provided closure. + /// - Parameters: + /// - userId: The user identifier to authenticate. + /// - completion: Called with `.success(token)` or `.failure(error)`. + /// Invoked on the queue chosen by the client's `AuthTokenProvider` — no specific queue is guaranteed. + public func getToken(userId: String, completion: @escaping (Result) -> Void) { + provider(userId) { token, error in + if let token = token { + completion(.success(token)) + } else { + completion(.failure(error ?? AuthError.tokenFetchFailed)) + } + } + } +} + +#if swift(>=5.5) +extension AuthManager: @unchecked Sendable {} +#endif diff --git a/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift b/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift new file mode 100644 index 00000000..db92d1bd --- /dev/null +++ b/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift @@ -0,0 +1,128 @@ +// Copyright © 2026 Optimove. All rights reserved. + +import Foundation + +/// Orchestrates event dispatch: groups events by customer, resolves JWTs via AuthManager, +/// and delegates each group to the underlying `OptistreamNetworking` transport. +/// +/// Callers receive per-group results via `onGroupResult`, allowing them to handle partial +/// success (e.g. remove successfully sent events from a persistent queue while keeping +/// failed ones for retry). +public protocol OptistreamDispatcher { + /// Send a batch of events, potentially splitting them by customer identity for auth. + /// + /// - Parameters: + /// - events: The batch of events to send. + /// - path: Optional path appended to the endpoint URL. + /// - onGroupResult: Called once per customer group with that group's send result. + /// Groups are processed sequentially; the next group starts only after the previous + /// one's `onGroupResult` fires. + /// - completion: Called once after all groups have been processed. + func sendBatch( + events: [OptistreamEvent], + path: String?, + onGroupResult: @escaping (_ groupEvents: [OptistreamEvent], _ result: Result) -> Void, + completion: @escaping () -> Void + ) +} + +public final class OptistreamDispatcherImpl: OptistreamDispatcher { + private let networking: OptistreamNetworking + private let authManager: AuthManager? + + public init( + networking: OptistreamNetworking, + authManager: AuthManager? = nil + ) { + self.networking = networking + self.authManager = authManager + } + + public func sendBatch( + events: [OptistreamEvent], + path: String?, + onGroupResult: @escaping (_ groupEvents: [OptistreamEvent], _ result: Result) -> Void, + completion: @escaping () -> Void + ) { + guard let authManager = authManager else { + // No auth configured — send the entire batch as-is, no JWT + networking.send(events: events, path: path, jwt: nil) { result in + onGroupResult(events, result) + completion() + } + return + } + + // Group events by customer identity so each request carries a single JWT. + // Anonymous events (customer nil/empty) are grouped together and sent without JWT. + let grouped = Dictionary(grouping: events) { $0.customer ?? "" } + let groups = Array(grouped) + + if grouped.count <= 1 { + // All events belong to the same customer (or all anonymous) — no splitting needed + let customerId = groups.first?.key + sendGroup( + events: events, + path: path, + customerId: customerId, + authManager: authManager, + onGroupResult: onGroupResult, + completion: completion + ) + return + } + + // Multiple customers — process groups sequentially + func processNext(_ index: Int) { + guard index < groups.count else { + completion() + return + } + let (customerId, groupEvents) = groups[index] + sendGroup( + events: groupEvents, + path: path, + customerId: customerId, + authManager: authManager, + onGroupResult: onGroupResult + ) { + processNext(index + 1) + } + } + processNext(0) + } + + /// Resolve JWT for a single customer group and send via the networking transport. + private func sendGroup( + events: [OptistreamEvent], + path: String?, + customerId: String?, + authManager: AuthManager, + onGroupResult: @escaping ([OptistreamEvent], Result) -> Void, + completion: @escaping () -> Void + ) { + guard let customerId = customerId, !customerId.isEmpty else { + networking.send(events: events, path: path, jwt: nil) { result in + onGroupResult(events, result) + completion() + } + return + } + + authManager.getToken(userId: customerId) { [networking] tokenResult in + switch tokenResult { + case .success(let jwt): + networking.send(events: events, path: path, jwt: jwt) { result in + onGroupResult(events, result) + completion() + } + case .failure(let error): + Logger.error( + "Auth token fetch failed for userId '\(customerId)': \(error.localizedDescription). Dropping group." + ) + onGroupResult(events, .failure(NetworkError.error(error))) + completion() + } + } + } +} diff --git a/OptimoveCore/Sources/Classes/Optistream/OptistreamNetworking.swift b/OptimoveCore/Sources/Classes/Optistream/OptistreamNetworking.swift index 84173a50..57a4d435 100644 --- a/OptimoveCore/Sources/Classes/Optistream/OptistreamNetworking.swift +++ b/OptimoveCore/Sources/Classes/Optistream/OptistreamNetworking.swift @@ -5,12 +5,8 @@ import Foundation public protocol OptistreamNetworking { func send( events: [OptistreamEvent], - path: String, - completion: @escaping (Result) -> Void - ) - - func send( - events: [OptistreamEvent], + path: String?, + jwt: String?, completion: @escaping (Result) -> Void ) } @@ -26,17 +22,26 @@ public final class OptistreamNetworkingImpl { self.networkClient = networkClient self.endpoint = endpoint } +} - private func _send( +extension OptistreamNetworkingImpl: OptistreamNetworking { + public func send( events: [OptistreamEvent], path: String?, + jwt: String?, completion: @escaping (Result) -> Void ) { + var headers: [HTTPHeader] = [] + if let jwt = jwt { + headers.append(HTTPHeader(field: .userJwt, value: jwt)) + } + do { let request = try NetworkRequest( method: .post, baseURL: endpoint, path: path, + headers: headers, body: events ) networkClient.perform(request) { @@ -48,23 +53,6 @@ public final class OptistreamNetworkingImpl { } } -extension OptistreamNetworkingImpl: OptistreamNetworking { - public func send( - events: [OptistreamEvent], - path: String, - completion: @escaping (Result) -> Void - ) { - _send(events: events, path: path, completion: completion) - } - - public func send( - events: [OptistreamEvent], - completion: @escaping (Result) -> Void - ) { - _send(events: events, path: nil, completion: completion) - } -} - private extension OptistreamNetworkingImpl { static func handleResult(result: Result, NetworkError>, for events: [OptistreamEvent], diff --git a/OptimoveSDK/Sources/Classes/Components/OptiTrack/OptiTrack.swift b/OptimoveSDK/Sources/Classes/Components/OptiTrack/OptiTrack.swift index d470555a..51159c1b 100644 --- a/OptimoveSDK/Sources/Classes/Components/OptiTrack/OptiTrack.swift +++ b/OptimoveSDK/Sources/Classes/Components/OptiTrack/OptiTrack.swift @@ -6,7 +6,7 @@ import UIKit typealias OptistreamEvent = OptimoveCore.OptistreamEvent typealias OptistreamEventBuilder = OptimoveCore.OptistreamEventBuilder -typealias OptistreamNetworking = OptimoveCore.OptistreamNetworking +typealias OptistreamDispatcher = OptimoveCore.OptistreamDispatcher final class OptiTrack { enum Constants { @@ -20,7 +20,7 @@ final class OptiTrack { } private let queue: OptistreamQueue - private let networking: OptistreamNetworking + private let dispatcher: OptistreamDispatcher private let configuration: OptitrackConfig private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid private var dispatchTimer: Timer? @@ -30,11 +30,11 @@ final class OptiTrack { init( queue: OptistreamQueue, - networking: OptistreamNetworking, + dispatcher: OptistreamDispatcher, configuration: OptitrackConfig ) { self.queue = queue - self.networking = networking + self.dispatcher = dispatcher self.configuration = configuration startDispatchTimer() } @@ -154,25 +154,35 @@ private extension OptiTrack { Logger.info("Finished dispatching events") return } - networking.send(events: events) { [weak self] result in - guard let self = self else { return } - self.dispatchQueue.async { - switch result { - case .success: - self.queue.remove(events: events) - self.dispatchBatch() - case let .failure(error): - Logger.error(error.localizedDescription) - switch error { - case .requestInvalid: - self.queue.remove(events: events) - default: - break + + var hasRetryableError = false + + dispatcher.sendBatch( + events: events, + path: nil, + onGroupResult: { [weak self] groupEvents, result in + guard let self = self else { return } + self.dispatchQueue.async { + switch result { + case .success: + self.queue.remove(events: groupEvents) + case let .failure(error): + Logger.error(error.localizedDescription) + hasRetryableError = true + } + } + }, + completion: { [weak self] in + guard let self = self else { return } + self.dispatchQueue.async { + if hasRetryableError { + self.stopDispatching() + } else { + self.dispatchBatch() } - self.stopDispatching() } } - } + ) } func stopDispatching() { diff --git a/OptimoveSDK/Sources/Classes/Components/RealTime/RealTime.swift b/OptimoveSDK/Sources/Classes/Components/RealTime/RealTime.swift index 861574c8..b27ae14c 100644 --- a/OptimoveSDK/Sources/Classes/Components/RealTime/RealTime.swift +++ b/OptimoveSDK/Sources/Classes/Components/RealTime/RealTime.swift @@ -17,19 +17,19 @@ final class RealTime { private let realTimeQueue = DispatchQueue(label: "com.optimove.sdk.realtime", qos: .userInitiated) private var storage: OptimoveStorage private let queue: OptistreamQueue - private let networking: OptistreamNetworking + private let dispatcher: OptistreamDispatcher // MARK: - Public required init( configuration: RealtimeConfig, storage: OptimoveStorage, - networking: OptistreamNetworking, + dispatcher: OptistreamDispatcher, queue: OptistreamQueue ) { self.configuration = configuration self.storage = storage - self.networking = networking + self.dispatcher = dispatcher self.queue = queue } } @@ -90,19 +90,23 @@ private extension RealTime { // MARK: Send report func sentReportEvent(_ events: [OptistreamEvent]) { - networking.send(events: events, path: Constants.path) { [weak self] result in - guard let self = self else { return } - self.realTimeQueue.async { [weak self] in + dispatcher.sendBatch( + events: events, + path: Constants.path, + onGroupResult: { [weak self] groupEvents, result in guard let self = self else { return } - switch result { - case .success: - self.onSuccess(events) - case let .failure(error): - Logger.error(error.localizedDescription) - self.onError(events) + self.realTimeQueue.async { + switch result { + case .success: + self.onSuccess(groupEvents) + case let .failure(error): + Logger.error(error.localizedDescription) + self.onError(groupEvents) + } } - } - } + }, + completion: { } + ) } func onSuccess(_ events: [OptistreamEvent]) { diff --git a/OptimoveSDK/Sources/Classes/Factories/ComponentFactory.swift b/OptimoveSDK/Sources/Classes/Factories/ComponentFactory.swift index e6a4cdfb..42200cee 100644 --- a/OptimoveSDK/Sources/Classes/Factories/ComponentFactory.swift +++ b/OptimoveSDK/Sources/Classes/Factories/ComponentFactory.swift @@ -7,24 +7,32 @@ final class ComponentFactory { private let serviceLocator: ServiceLocator private let coreEventFactory: CoreEventFactory private let persistentContainer: PersistentContainer + private let authManager: AuthManager? init(serviceLocator: ServiceLocator, - coreEventFactory: CoreEventFactory) + coreEventFactory: CoreEventFactory, + authManager: AuthManager? = nil) { self.serviceLocator = serviceLocator self.coreEventFactory = coreEventFactory + self.authManager = authManager persistentContainer = PersistentContainer() } func createRealtimeComponent(configuration: Configuration) throws -> RealTime { let storage = serviceLocator.storage() + let networking = OptistreamNetworkingImpl( + networkClient: serviceLocator.networkClient(), + endpoint: configuration.realtime.realtimeGateway + ) + let dispatcher = OptistreamDispatcherImpl( + networking: networking, + authManager: authManager + ) return try RealTime( configuration: configuration.realtime, storage: storage, - networking: OptistreamNetworkingImpl( - networkClient: serviceLocator.networkClient(), - endpoint: configuration.realtime.realtimeGateway - ), + dispatcher: dispatcher, queue: OptistreamQueueImpl( queueType: .realtime, container: persistentContainer, @@ -34,16 +42,21 @@ final class ComponentFactory { } func createOptitrackComponent(configuration: Configuration) throws -> OptiTrack { + let networking = OptistreamNetworkingImpl( + networkClient: serviceLocator.networkClient(), + endpoint: configuration.optitrack.optitrackEndpoint + ) + let dispatcher = OptistreamDispatcherImpl( + networking: networking, + authManager: authManager + ) return try OptiTrack( queue: OptistreamQueueImpl( queueType: .track, container: persistentContainer, tenant: configuration.tenantID ), - networking: OptistreamNetworkingImpl( - networkClient: serviceLocator.networkClient(), - endpoint: configuration.optitrack.optitrackEndpoint - ), + dispatcher: dispatcher, configuration: configuration.optitrack ) } diff --git a/OptimoveSDK/Sources/Classes/Optimove.swift b/OptimoveSDK/Sources/Classes/Optimove.swift index 3e69bce8..56f449f0 100644 --- a/OptimoveSDK/Sources/Classes/Optimove.swift +++ b/OptimoveSDK/Sources/Classes/Optimove.swift @@ -32,12 +32,13 @@ typealias Logger = OptimoveCore.Logger } /// The starting point of the Optimove SDK. - static func configure(for config: OptimoveConfig) { + static func configure(for config: OptimoveConfig, authManager: AuthManager? = nil) { /// FUTURE: To merge configure call with init. shared.container.resolve { serviceLocator in if let tenantInfo = config.tenantInfo { serviceLocator.newTenantInfoHandler().handle(tenantInfo) } + serviceLocator.authManager = authManager serviceLocator.deviceStateObserver().start() shared.startSDK { _ in } } @@ -46,8 +47,10 @@ typealias Logger = OptimoveCore.Logger public static func initialize(with config: OptimoveConfig) { shared.config = config + let authManager: AuthManager? = config.authTokenProvider.map { AuthManager(provider: $0) } + if config.isOptimoveConfigured() { - Optimove.configure(for: config) + Optimove.configure(for: config, authManager: authManager) } if config.isOptimobileConfigured() { @@ -56,7 +59,7 @@ typealias Logger = OptimoveCore.Logger let visitorId = try serviceLocator.storage().getInitialVisitorId() let userId = try? serviceLocator.storage().getCustomerID() - try Optimobile.initialize(config: config, initialVisitorId: visitorId, initialUserId: userId) + try Optimobile.initialize(config: config, initialVisitorId: visitorId, initialUserId: userId, authManager: authManager) } catch { throw GuardError.custom("Failed on OptimobileSDK initialization. Reason: \(error.localizedDescription)") } @@ -66,7 +69,7 @@ typealias Logger = OptimoveCore.Logger if config.isEmbeddedMessagingConfigured() { shared.container.resolve { serviceLocator in do { - try EmbeddedMessagesService.initialize(with: config, storage: serviceLocator.storage(), networkClient: NetworkClientImpl()) + try EmbeddedMessagesService.initialize(with: config, storage: serviceLocator.storage(), networkClient: NetworkClientImpl(), authManager: authManager) } catch { throw GuardError.custom("Failed on Embedded Messaging initialization. Reason: \(error.localizedDescription)") } @@ -81,7 +84,7 @@ typealias Logger = OptimoveCore.Logger shared.container.resolve { serviceLocator in do { - try OptimovePreferenceCenter.initialize(with: config, storage: serviceLocator.storage(), networkClient: NetworkClientImpl()) + try OptimovePreferenceCenter.initialize(with: config, storage: serviceLocator.storage(), networkClient: NetworkClientImpl(), authManager: authManager) } catch { throw GuardError.custom("Failed on PreferenceCenter initialization. Reason: \(error.localizedDescription)") } diff --git a/OptimoveSDK/Sources/Classes/OptimoveConfig.swift b/OptimoveSDK/Sources/Classes/OptimoveConfig.swift index 84659612..bda28c7e 100644 --- a/OptimoveSDK/Sources/Classes/OptimoveConfig.swift +++ b/OptimoveSDK/Sources/Classes/OptimoveConfig.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Optimove. All rights reserved. import Foundation +import OptimoveCore /// A set of options for configuring the SDK. /// - Note: The SDK can be configured to support multiple features. @@ -49,6 +50,7 @@ public struct OptimoveConfig { let optimobileConfig: OptimobileConfig? let preferenceCenterConfig: PreferenceCenterConfig? let embeddedMessagingConfig: EmbeddedMessagingConfig? + let authTokenProvider: AuthTokenProvider? func isOptimoveConfigured() -> Bool { return features.contains(.optimove) @@ -130,6 +132,7 @@ open class OptimoveConfigBuilder: NSObject { private var _pushReceivedInForegroundHandlerBlock: Any? private var _deepLinkCname: URL? private var _deepLinkHandler: DeepLinkHandler? + private var _authTokenProvider: AuthTokenProvider? private var _runtimeInfo: [String: AnyObject]? private var _sdkInfo: [String: AnyObject]? private var _isRelease: Bool? @@ -173,6 +176,7 @@ open class OptimoveConfigBuilder: NSObject { _sdkInfo = optimobileConfig.sdkInfo _isRelease = optimobileConfig.isRelease } + _authTokenProvider = config.authTokenProvider features = config.features } @@ -294,6 +298,20 @@ open class OptimoveConfigBuilder: NSObject { return self } + /// Enable JWT-based federated authentication for all user-identified requests. + /// + /// When enabled, the SDK will call this closure before each user-identified request to obtain + /// a JWT, which is attached via the `X-User-JWT` header. + /// + /// - Parameter provider: A closure that receives a userId and a completion callback. + /// Call `completion(jwt, nil)` on success or `completion(nil, error)` on failure. + /// - Returns: The builder instance for chaining. + @discardableResult public func enableAuth( + _ provider: @escaping AuthTokenProvider + ) -> OptimoveConfigBuilder { + _authTokenProvider = provider + return self + } /** Internal SDK embedding API to support override of stats data in x-plat SDKs. Do not call or depend on this method in your app @@ -408,7 +426,8 @@ open class OptimoveConfigBuilder: NSObject { tenantInfo: tenantInfo, optimobileConfig: optimobileConfig, preferenceCenterConfig: preferenceCenterConfig, - embeddedMessagingConfig: embeddedMessagingConfig + embeddedMessagingConfig: embeddedMessagingConfig, + authTokenProvider: _authTokenProvider ) } diff --git a/OptimoveSDK/Sources/Classes/Services/ServiceLocator.swift b/OptimoveSDK/Sources/Classes/Services/ServiceLocator.swift index 74cdb1f6..ed1c6f92 100644 --- a/OptimoveSDK/Sources/Classes/Services/ServiceLocator.swift +++ b/OptimoveSDK/Sources/Classes/Services/ServiceLocator.swift @@ -28,6 +28,9 @@ final class ServiceLocator { storage: storage() ).build() + /// Auth manager for JWT-based federated authentication (nil if auth not configured). + var authManager: AuthManager? + // MARK: - Initializer init(storageFacade: StorageFacade) { @@ -111,7 +114,8 @@ final class ServiceLocator { func componentFactory() -> ComponentFactory { return ComponentFactory( serviceLocator: self, - coreEventFactory: coreEventFactory() + coreEventFactory: coreEventFactory(), + authManager: authManager ) } From 75d65e6773ddb67348e515ffb40445a443690704 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Mon, 16 Feb 2026 12:41:04 -0500 Subject: [PATCH 02/16] Added auth to pref center and embedded --- .../Classes/NetworkClient/NetworkClient.swift | 3 + .../NetworkClient/NetworkRequest.swift | 6 + .../EmbeddedMessaging/EmbeddedMessaging.swift | 342 +++++++++++------- .../OptimovePreferenceCenter.swift | 161 ++++++--- 4 files changed, 323 insertions(+), 189 deletions(-) diff --git a/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift b/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift index 236bfa44..3bacd352 100644 --- a/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift +++ b/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift @@ -53,6 +53,9 @@ extension NetworkClientImpl: NetworkClient { request.headers?.forEach { urlRequest.addValue($0.value, forHTTPHeaderField: $0.field) } + // Signal that this SDK version supports auth. + urlRequest.addValue("1", forHTTPHeaderField: "X-Optimove-Auth-Capable") + let task = session.dataTask(with: urlRequest) { data, response, error in if let error = error { completion(.failure(NetworkError.error(error))) diff --git a/OptimoveCore/Sources/Classes/NetworkClient/NetworkRequest.swift b/OptimoveCore/Sources/Classes/NetworkClient/NetworkRequest.swift index 74e80b91..cdc908bf 100644 --- a/OptimoveCore/Sources/Classes/NetworkClient/NetworkRequest.swift +++ b/OptimoveCore/Sources/Classes/NetworkClient/NetworkRequest.swift @@ -88,6 +88,7 @@ public extension HTTPHeader { case contentType = "Content-Type" case userAgent = "User-Agent" case tenantId = "X-Tenant-Id" + case userJwt = "X-User-JWT" case accept } @@ -114,6 +115,11 @@ public extension HTTPHeader { self.field = field.rawValue self.value = String(describing: value) } + + init(field: Fields, value: String) { + self.field = field.rawValue + self.value = value + } } extension HTTPHeader: CustomStringConvertible { diff --git a/OptimoveSDK/Sources/Classes/EmbeddedMessaging/EmbeddedMessaging.swift b/OptimoveSDK/Sources/Classes/EmbeddedMessaging/EmbeddedMessaging.swift index 045faef9..119edb72 100644 --- a/OptimoveSDK/Sources/Classes/EmbeddedMessaging/EmbeddedMessaging.swift +++ b/OptimoveSDK/Sources/Classes/EmbeddedMessaging/EmbeddedMessaging.swift @@ -51,6 +51,7 @@ public class EmbeddedMessagesService { internal static var instance: EmbeddedMessagesService? private var storage: OptimoveStorage? private var networkClient: NetworkClient? + private var authManager: AuthManager? private var payload: [String: Any] = [:] private func handleRequestError(_ error: Swift.Error) { @@ -77,15 +78,15 @@ public class EmbeddedMessagesService { return try JSONDecoder().decode(T.self, from: data) } - public static func initialize(with optimoveConfig: OptimoveConfig, storage: OptimoveStorage, networkClient: NetworkClient) throws { - print("🔧 Initializing EmbeddedMessagesService...") + public static func initialize(with optimoveConfig: OptimoveConfig, storage: OptimoveStorage, networkClient: NetworkClient, authManager: AuthManager? = nil) throws { + Logger.info("Initializing EmbeddedMessagesService...") if instance != nil { if optimoveConfig.features.contains(.delayedConfiguration), optimoveConfig.getEmbeddedMessagingConfig() == nil { throw Error.configurationIsMissing } - print("⚠️ EmbeddedMessagesService already initialized") + Logger.warn("EmbeddedMessagesService already initialized") return } @@ -94,13 +95,14 @@ public class EmbeddedMessagesService { throw Error.alreadyInitialized } - instance = EmbeddedMessagesService(storage: storage, networkClient: networkClient) - print("✅ EmbeddedMessagesService initialized") + instance = EmbeddedMessagesService(storage: storage, networkClient: networkClient, authManager: authManager) + Logger.info("EmbeddedMessagesService initialized") } - internal init(storage: OptimoveStorage, networkClient: NetworkClient) { + internal init(storage: OptimoveStorage, networkClient: NetworkClient, authManager: AuthManager? = nil) { self.storage = storage self.networkClient = networkClient + self.authManager = authManager } @@ -135,48 +137,56 @@ public class EmbeddedMessagesService { return } - do { - let request = try createGetEmbeddedMessagesRequest( - customerId: customerId, - visitorId: visitorId, - config: config, - containers: containers - ) - - networkClient?.perform(request) { result in - switch result { - case .success(let response): - do { - let apiResponse = try response.decode(to: EmbeddedMessagingAPIResponse.self) - var containers: EmbeddedMessagesResponse = [:] - for (containerId, messages) in apiResponse.containers { - containers[containerId] = EmbeddedMessagingContainer(containerId: containerId, messages: messages) + resolveJWT(userId: customerId, action: { [weak self] jwt in + guard let self = self else { return } + do { + let request = try self.createGetEmbeddedMessagesRequest( + customerId: customerId, + visitorId: visitorId, + config: config, + containers: containers, + jwt: jwt + ) + + self.networkClient?.perform(request) { result in + switch result { + case .success(let response): + do { + let apiResponse = try response.decode(to: EmbeddedMessagingAPIResponse.self) + var containers: EmbeddedMessagesResponse = [:] + for (containerId, messages) in apiResponse.containers { + containers[containerId] = EmbeddedMessagingContainer(containerId: containerId, messages: messages) + } + + DispatchQueue.main.async { + completion(.successMessages(containers)) + } + } catch { + self.handleRequestError(error) + DispatchQueue.main.async { + completion(.error(.errorSendingRequest)) + } } - DispatchQueue.main.async { - completion(.successMessages(containers)) - } - } catch { + case .failure(let error): self.handleRequestError(error) DispatchQueue.main.async { completion(.error(.errorSendingRequest)) } } + } - case .failure(let error): - self.handleRequestError(error) - DispatchQueue.main.async { - completion(.error(.errorSendingRequest)) - } + } catch { + self.handleRequestError(error) + DispatchQueue.main.async { + completion(.error(.errorSendingRequest)) } } - - } catch { - handleRequestError(error) + }, onFailure: { _ in DispatchQueue.main.async { completion(.error(.errorSendingRequest)) } - } + }) } /// Deletes the given message from the server. @@ -204,39 +214,44 @@ public class EmbeddedMessagesService { return } - do { - let request = try createReportEventRequest( - customerId: customerId, - visitorId: visitorId, - message: message, - event: EventType.delete, - config: config - ) - - print("➡️ Request: \(request)") - - - networkClient?.perform(request) { result in - switch result { - case .success: - DispatchQueue.main.async { - completion(.success) - } + resolveJWT(userId: customerId, action: { [weak self] jwt in + guard let self = self else { return } + do { + let request = try self.createReportEventRequest( + customerId: customerId, + visitorId: visitorId, + message: message, + event: EventType.delete, + config: config, + jwt: jwt + ) + + self.networkClient?.perform(request) { result in + switch result { + case .success: + DispatchQueue.main.async { + completion(.success) + } - case .failure(let error): - self.handleRequestError(error) - DispatchQueue.main.async { - completion(.error(.errorSendingRequest)) + case .failure(let error): + self.handleRequestError(error) + DispatchQueue.main.async { + completion(.error(.errorSendingRequest)) + } } } - } - } catch { - self.handleRequestError(error) + } catch { + self.handleRequestError(error) + DispatchQueue.main.async { + completion(.error(.errorSendingRequest)) + } + } + }, onFailure: { _ in DispatchQueue.main.async { completion(.error(.errorSendingRequest)) } - } + }) } /// Updates the read status of the given message on the server. @@ -266,36 +281,44 @@ public class EmbeddedMessagesService { return } - do { - let request = try createReportEventRequest( - customerId: customerId, - visitorId: visitorId, - message: message, - event: EventType.markAsRead, - config: config - ) - - networkClient?.perform(request) { result in - switch result { - case .success: - DispatchQueue.main.async { - completion(.success) - } + resolveJWT(userId: customerId, action: { [weak self] jwt in + guard let self = self else { return } + do { + let request = try self.createReportEventRequest( + customerId: customerId, + visitorId: visitorId, + message: message, + event: EventType.markAsRead, + config: config, + jwt: jwt + ) + + self.networkClient?.perform(request) { result in + switch result { + case .success: + DispatchQueue.main.async { + completion(.success) + } - case .failure(let error): - self.handleRequestError(error) - DispatchQueue.main.async { - completion(.error(.errorSendingRequest)) + case .failure(let error): + self.handleRequestError(error) + DispatchQueue.main.async { + completion(.error(.errorSendingRequest)) + } } } - } - } catch { - self.handleRequestError(error) + } catch { + self.handleRequestError(error) + DispatchQueue.main.async { + completion(.error(.errorSendingRequest)) + } + } + }, onFailure: { _ in DispatchQueue.main.async { completion(.error(.errorSendingRequest)) } - } + }) } /// Updates the read status of the given message on the server. @@ -325,36 +348,44 @@ public class EmbeddedMessagesService { return } - do { - let request = try createReportEventRequest( - customerId: customerId, - visitorId: visitorId, - message: message, - event: EventType.markAsUnread, - config: config - ) - - networkClient?.perform(request) { result in - switch result { - case .success: - DispatchQueue.main.async { - completion(.success) - } + resolveJWT(userId: customerId, action: { [weak self] jwt in + guard let self = self else { return } + do { + let request = try self.createReportEventRequest( + customerId: customerId, + visitorId: visitorId, + message: message, + event: EventType.markAsUnread, + config: config, + jwt: jwt + ) + + self.networkClient?.perform(request) { result in + switch result { + case .success: + DispatchQueue.main.async { + completion(.success) + } - case .failure(let error): - self.handleRequestError(error) - DispatchQueue.main.async { - completion(.error(.errorSendingRequest)) + case .failure(let error): + self.handleRequestError(error) + DispatchQueue.main.async { + completion(.error(.errorSendingRequest)) + } } } - } - } catch { - self.handleRequestError(error) + } catch { + self.handleRequestError(error) + DispatchQueue.main.async { + completion(.error(.errorSendingRequest)) + } + } + }, onFailure: { _ in DispatchQueue.main.async { completion(.error(.errorSendingRequest)) } - } + }) } /// Reports a click metric for the given message. @@ -381,38 +412,44 @@ public class EmbeddedMessagesService { return } - do { - let request = try createReportEventRequest( - customerId: customerId, - visitorId: visitorId, - message: message, - event: EventType.clickMetric, - config: config - ) - - print("request: \(String(describing: request))") - - networkClient?.perform(request) { result in - switch result { - case .success: - DispatchQueue.main.async { - completion(.success) - } + resolveJWT(userId: customerId, action: { [weak self] jwt in + guard let self = self else { return } + do { + let request = try self.createReportEventRequest( + customerId: customerId, + visitorId: visitorId, + message: message, + event: EventType.clickMetric, + config: config, + jwt: jwt + ) + + self.networkClient?.perform(request) { result in + switch result { + case .success: + DispatchQueue.main.async { + completion(.success) + } - case .failure(let error): - self.handleRequestError(error) - DispatchQueue.main.async { - completion(.error(.errorSendingRequest)) + case .failure(let error): + self.handleRequestError(error) + DispatchQueue.main.async { + completion(.error(.errorSendingRequest)) + } } } - } - } catch { - self.handleRequestError(error) + } catch { + self.handleRequestError(error) + DispatchQueue.main.async { + completion(.error(.errorSendingRequest)) + } + } + }, onFailure: { _ in DispatchQueue.main.async { completion(.error(.errorSendingRequest)) } - } + }) } @@ -421,7 +458,8 @@ public class EmbeddedMessagesService { customerId: String, visitorId: String, config: EmbeddedMessagingConfig, - containers: [ContainerRequestOptions]? = nil + containers: [ContainerRequestOptions]? = nil, + jwt: String? = nil ) throws -> NetworkRequest { let (region, brandId, tenantId) = getConfigValues(from: config) @@ -438,9 +476,12 @@ public class EmbeddedMessagesService { let encoder = JSONEncoder() let bodyData = try encoder.encode(containers) - let headers: [HTTPHeader] = [ + var headers: [HTTPHeader] = [ HTTPHeader(field: .contentType, value: .json) ] + if let jwt = jwt { + headers.append(HTTPHeader(field: .userJwt, value: jwt)) + } return NetworkRequest( method: .post, @@ -459,7 +500,8 @@ public class EmbeddedMessagesService { visitorId: String, message: EmbeddedMessage, event: EventType, - config: EmbeddedMessagingConfig + config: EmbeddedMessagingConfig, + jwt: String? = nil ) throws -> NetworkRequest { let (region, brandId, tenantId) = getConfigValues(from: config) @@ -467,10 +509,13 @@ public class EmbeddedMessagesService { let path = "/api/v2/events/report" // Headers - let headers: [HTTPHeader] = [ + var headers: [HTTPHeader] = [ HTTPHeader(field: .contentType, value: .json), HTTPHeader(field: .tenantId, value: .tenantId(id: tenantId)) ] + if let jwt = jwt { + headers.append(HTTPHeader(field: .userJwt, value: jwt)) + } let queryItems: [URLQueryItem] = [ URLQueryItem(name: "TenantId", value: tenantId), @@ -503,6 +548,31 @@ public class EmbeddedMessagesService { } + // MARK: - Auth Helper + + /// Resolves a JWT if auth is configured, then calls `action`. + /// If auth is not configured, calls `action(nil)` (proceed without JWT). + /// If auth is configured but the token fetch fails, calls `onFailure` (fail-closed). + private func resolveJWT( + userId: String, + action: @escaping (_ jwt: String?) -> Void, + onFailure: @escaping (_ error: Error) -> Void + ) { + guard let authManager = authManager else { + action(nil) + return + } + authManager.getToken(userId: userId) { result in + switch result { + case .success(let jwt): + action(jwt) + case .failure(let error): + Logger.error("Auth token fetch failed for EmbeddedMessaging: \(error.localizedDescription). Dropping request.") + onFailure(error) + } + } + } + // MARK: - Log Failed Response private func logFailedResponse(_ error: Swift.Error) { Logger.error("Request failed: \(error.localizedDescription)") diff --git a/OptimoveSDK/Sources/Classes/PreferenceCenter/OptimovePreferenceCenter.swift b/OptimoveSDK/Sources/Classes/PreferenceCenter/OptimovePreferenceCenter.swift index ab90416b..ce459946 100644 --- a/OptimoveSDK/Sources/Classes/PreferenceCenter/OptimovePreferenceCenter.swift +++ b/OptimoveSDK/Sources/Classes/PreferenceCenter/OptimovePreferenceCenter.swift @@ -22,6 +22,7 @@ public class OptimovePreferenceCenter { private static var instance: OptimovePreferenceCenter? private var networkClient: NetworkClient? private var storage: OptimoveStorage? + private var authManager: AuthManager? static var isSdkRunning: Bool { return Optimove.getConfig()?.getPreferenceCenterConfig() != nil @@ -44,7 +45,7 @@ public class OptimovePreferenceCenter { return instance } - static func initialize(with optimoveConfig: OptimoveConfig, storage: OptimoveStorage, networkClient: NetworkClient) throws { + static func initialize(with optimoveConfig: OptimoveConfig, storage: OptimoveStorage, networkClient: NetworkClient, authManager: AuthManager? = nil) throws { if instance !== nil, optimoveConfig.features.contains(.delayedConfiguration) { guard optimoveConfig.preferenceCenterConfig != nil else { throw Error.configurationIsMissing @@ -57,12 +58,13 @@ public class OptimovePreferenceCenter { throw Error.alreadyInitialized } - instance = OptimovePreferenceCenter(storage: storage, networkClient: networkClient) + instance = OptimovePreferenceCenter(storage: storage, networkClient: networkClient, authManager: authManager) } - private init(storage: OptimoveStorage, networkClient: NetworkClient) { + private init(storage: OptimoveStorage, networkClient: NetworkClient, authManager: AuthManager?) { self.networkClient = networkClient self.storage = storage + self.authManager = authManager } public func getPreferencesAsync(completion: @escaping PreferencesGetHandler) { @@ -82,51 +84,65 @@ public class OptimovePreferenceCenter { return } - do { - let request = try createGetPreferencesRequest(for: customerId, with: config) + resolveJWT(userId: customerId, action: { [weak self] jwt in + guard let self = self else { return } + do { + let request = try self.createGetPreferencesRequest(for: customerId, with: config, jwt: jwt) - networkClient?.perform(request) { [self] result in - switch result { - case .success(let response): - do { - let preferences = try response.decode(to: OptimovePC.Preferences.self) - DispatchQueue.main.async { - completion(.success, preferences) + self.networkClient?.perform(request) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let response): + do { + let preferences = try response.decode(to: OptimovePC.Preferences.self) + DispatchQueue.main.async { + completion(.success, preferences) + } + } catch { + self.logFailedResponse(error) + DispatchQueue.main.async { + completion(.error, nil) + } } - } catch { - logFailedResponse(error) + case .failure(let error): + self.logFailedResponse(error) DispatchQueue.main.async { completion(.error, nil) } } - case .failure(let error): - logFailedResponse(error) - DispatchQueue.main.async { - completion(.error, nil) - } } - } - } catch { - logFailedResponse(error) + } catch { + self.logFailedResponse(error) + DispatchQueue.main.async { + completion(.error, nil) + } + } + }, onFailure: { _ in DispatchQueue.main.async { completion(.error, nil) } - } + }) } private func createGetPreferencesRequest( for customerId: String, - with config: PreferenceCenterConfig) throws -> NetworkRequest { + with config: PreferenceCenterConfig, + jwt: String? = nil) throws -> NetworkRequest { let (region, brandGroupId, tenantId) = getConfigValues(from: config) + var headers = [ + HTTPHeader(field: .accept, value: .textplain), + HTTPHeader(field: .tenantId, value: .tenantId(id: tenantId)) + ] + if let jwt = jwt { + headers.append(HTTPHeader(field: .userJwt, value: jwt)) + } + return NetworkRequest( method: .get, baseURL: URL(string: "https://preference-center-\(region).optimove.net")!, path: "/api/v1/preferences", - headers: [ - HTTPHeader(field: .accept, value: .textplain), - HTTPHeader(field: .tenantId, value: .tenantId(id: tenantId)) - ], + headers: headers, queryItems: [ URLQueryItem(name: "customerId", value: customerId), URLQueryItem(name: "brandGroupId", value: brandGroupId) @@ -157,53 +173,67 @@ public class OptimovePreferenceCenter { return } - do { - let request = try createSetPreferencesRequest(for: customerId, with: config, updates: updates) + resolveJWT(userId: customerId, action: { [weak self] jwt in + guard let self = self else { return } + do { + let request = try self.createSetPreferencesRequest(for: customerId, with: config, updates: updates, jwt: jwt) - networkClient?.perform(request) { [self] result in - switch result { - case .success(let response): - do { - _ = try response.unwrap() - DispatchQueue.main.async { - completion(.success) + self.networkClient?.perform(request) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let response): + do { + _ = try response.unwrap() + DispatchQueue.main.async { + completion(.success) + } + } catch { + self.logFailedResponse(error) + DispatchQueue.main.async { + completion(.error) + } } - } catch { - logFailedResponse(error) + case .failure(let error): + self.logFailedResponse(error) DispatchQueue.main.async { completion(.error) } } - case .failure(let error): - logFailedResponse(error) - DispatchQueue.main.async { - completion(.error) - } } - } - } catch { - logFailedResponse(error) + } catch { + self.logFailedResponse(error) + DispatchQueue.main.async { + completion(.error) + } + } + }, onFailure: { _ in DispatchQueue.main.async { completion(.error) } - } + }) } private func createSetPreferencesRequest( for customerId: String, with config: PreferenceCenterConfig, - updates: [OptimovePC.PreferenceUpdate]) throws -> NetworkRequest { + updates: [OptimovePC.PreferenceUpdate], + jwt: String? = nil) throws -> NetworkRequest { let (region, brandGroupId, tenantId) = getConfigValues(from: config) + var headers = [ + HTTPHeader(field: .accept, value: .textplain), + HTTPHeader(field: .tenantId, value: .tenantId(id: tenantId)) + ] + if let jwt = jwt { + headers.append(HTTPHeader(field: .userJwt, value: jwt)) + } + return try NetworkRequest( method: .put, baseURL: URL(string: "https://preference-center-\(region).optimove.net")!, path: "/api/v1/preferences", - headers: [ - HTTPHeader(field: .accept, value: .textplain), - HTTPHeader(field: .tenantId, value: .tenantId(id: tenantId)) - ], + headers: headers, queryItems: [ URLQueryItem(name: "customerId", value: customerId), URLQueryItem(name: "brandGroupId", value: brandGroupId) @@ -212,6 +242,31 @@ public class OptimovePreferenceCenter { ) } + // MARK: - Auth Helper + + /// Resolves a JWT if auth is configured, then calls `action`. + /// If auth is not configured, calls `action(nil)` (proceed without JWT). + /// If auth is configured but the token fetch fails, calls `onFailure` (fail-closed). + private func resolveJWT( + userId: String, + action: @escaping (_ jwt: String?) -> Void, + onFailure: @escaping (_ error: Error) -> Void + ) { + guard let authManager = authManager else { + action(nil) + return + } + authManager.getToken(userId: userId) { result in + switch result { + case .success(let jwt): + action(jwt) + case .failure(let error): + Logger.error("Auth token fetch failed for PreferenceCenter: \(error.localizedDescription). Dropping request.") + onFailure(error) + } + } + } + private func logFailedResponse(_ error: Swift.Error) { Logger.error("Request failed with error: \(error.localizedDescription)") } From adeb8e3b5804e22ede2f3514e09cd35fef0809d2 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Mon, 16 Feb 2026 13:17:33 -0500 Subject: [PATCH 03/16] Aligned tests with the changed types --- .../EmbeddedMessaging/EmbeddedMessaging.swift | 2 +- .../OptimovePreferenceCenter.swift | 2 +- .../OptiTrack/OptiTrackMockQueueTests.swift | 10 +++---- .../Components/OptiTrack/OptitrackTests.swift | 10 +++---- .../Realtime/RealtimeComponentTests.swift | 10 +++---- .../Network/EmbeddedMessagingTests.swift | 3 +- .../Mocks/OptistreamDispatcherMock.swift | 28 +++++++++++++++++++ .../Mocks/OptistreamNetworkingMock.swift | 6 +--- 8 files changed, 48 insertions(+), 23 deletions(-) create mode 100644 Shared/Sources/Mocks/OptistreamDispatcherMock.swift diff --git a/OptimoveSDK/Sources/Classes/EmbeddedMessaging/EmbeddedMessaging.swift b/OptimoveSDK/Sources/Classes/EmbeddedMessaging/EmbeddedMessaging.swift index 119edb72..de4c8792 100644 --- a/OptimoveSDK/Sources/Classes/EmbeddedMessaging/EmbeddedMessaging.swift +++ b/OptimoveSDK/Sources/Classes/EmbeddedMessaging/EmbeddedMessaging.swift @@ -556,7 +556,7 @@ public class EmbeddedMessagesService { private func resolveJWT( userId: String, action: @escaping (_ jwt: String?) -> Void, - onFailure: @escaping (_ error: Error) -> Void + onFailure: @escaping (_ error: Swift.Error) -> Void ) { guard let authManager = authManager else { action(nil) diff --git a/OptimoveSDK/Sources/Classes/PreferenceCenter/OptimovePreferenceCenter.swift b/OptimoveSDK/Sources/Classes/PreferenceCenter/OptimovePreferenceCenter.swift index ce459946..6b8d913a 100644 --- a/OptimoveSDK/Sources/Classes/PreferenceCenter/OptimovePreferenceCenter.swift +++ b/OptimoveSDK/Sources/Classes/PreferenceCenter/OptimovePreferenceCenter.swift @@ -250,7 +250,7 @@ public class OptimovePreferenceCenter { private func resolveJWT( userId: String, action: @escaping (_ jwt: String?) -> Void, - onFailure: @escaping (_ error: Error) -> Void + onFailure: @escaping (_ error: Swift.Error) -> Void ) { guard let authManager = authManager else { action(nil) diff --git a/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptiTrackMockQueueTests.swift b/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptiTrackMockQueueTests.swift index 6382d43b..9f0438b9 100644 --- a/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptiTrackMockQueueTests.swift +++ b/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptiTrackMockQueueTests.swift @@ -9,13 +9,13 @@ final class OptiTrackMockQueueTests: OptimoveTestCase { var optitrack: OptiTrack! var dateProvider = MockDateTimeProvider() var statisticService = MockStatisticService() - var networking: OptistreamNetworkingMock! + var dispatcher: OptistreamDispatcherMock! var queue: MockOptistreamQueue! var builder: OptistreamEventBuilder! let dispatchInterval: TimeInterval = 1 override func setUp() { - networking = OptistreamNetworkingMock() + dispatcher = OptistreamDispatcherMock() queue = MockOptistreamQueue() let configuration = ConfigurationFixture.build( Options(isEnableRealtime: true, isEnableRealtimeThroughOptistream: true) @@ -26,7 +26,7 @@ final class OptiTrackMockQueueTests: OptimoveTestCase { ) optitrack = OptiTrack( queue: queue, - networking: networking, + dispatcher: dispatcher, configuration: configuration.optitrack ) optitrack.dispatchInterval = dispatchInterval @@ -39,7 +39,7 @@ final class OptiTrackMockQueueTests: OptimoveTestCase { // then let networkExpectation = expectation(description: "track event haven't been generated.") - networking.assetEventsFunction = { events, _ in + dispatcher.assetEventsFunction = { events, _ in XCTAssertEqual(events.count, 1) networkExpectation.fulfill() } @@ -57,7 +57,7 @@ final class OptiTrackMockQueueTests: OptimoveTestCase { // then let networkExpectation = expectation(description: "track event haven't been generated.") - networking.assetEventsFunction = { events, _ in + dispatcher.assetEventsFunction = { events, _ in XCTAssertEqual(stubEvents.count, events.count) networkExpectation.fulfill() } diff --git a/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptitrackTests.swift b/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptitrackTests.swift index 7a646834..347e07c9 100644 --- a/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptitrackTests.swift +++ b/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptitrackTests.swift @@ -7,7 +7,7 @@ import XCTest class OptitrackTests: OptimoveTestCase { var optitrack: OptiTrack! - var networking: OptistreamNetworkingMock! + var dispatcher: OptistreamDispatcherMock! var builder: OptistreamEventBuilder! let dispatchInterval: TimeInterval = 1 @@ -15,7 +15,7 @@ class OptitrackTests: OptimoveTestCase { let configuration = ConfigurationFixture.build( Options(isEnableRealtime: true, isEnableRealtimeThroughOptistream: true) ) - networking = OptistreamNetworkingMock() + dispatcher = OptistreamDispatcherMock() let queue = try OptistreamQueueImpl( queueType: .track, container: PersistentContainer(), @@ -27,7 +27,7 @@ class OptitrackTests: OptimoveTestCase { ) optitrack = OptiTrack( queue: queue, - networking: networking, + dispatcher: dispatcher, configuration: configuration.optitrack ) optitrack?.dispatchInterval = dispatchInterval @@ -35,7 +35,7 @@ class OptitrackTests: OptimoveTestCase { override func tearDownWithError() throws { optitrack = nil - networking = nil + dispatcher = nil builder = nil } @@ -68,7 +68,7 @@ class OptitrackTests: OptimoveTestCase { ) batchExpectation.expectedFulfillmentCount = batchTimes var eventIds: [String] = [] - networking.assetEventsFunction = { events, completion in + dispatcher.assetEventsFunction = { events, completion in events.enumerated().forEach { event in let eventId = event.element.metadata.eventId if eventIds.contains(eventId) { diff --git a/OptimoveSDK/Tests/Sources/Components/Realtime/RealtimeComponentTests.swift b/OptimoveSDK/Tests/Sources/Components/Realtime/RealtimeComponentTests.swift index a4ee28b2..0db7ec7e 100644 --- a/OptimoveSDK/Tests/Sources/Components/Realtime/RealtimeComponentTests.swift +++ b/OptimoveSDK/Tests/Sources/Components/Realtime/RealtimeComponentTests.swift @@ -7,7 +7,7 @@ import XCTest class RealtimeComponentTests: OptimoveTestCase { var realtime: RealTime! - var networking = OptistreamNetworkingMock() + var dispatcher = OptistreamDispatcherMock() var queue = MockOptistreamQueue() func test_RT_events_order() throws { @@ -15,7 +15,7 @@ class RealtimeComponentTests: OptimoveTestCase { realtime = RealTime( configuration: configuration.realtime, storage: storage, - networking: networking, + dispatcher: dispatcher, queue: queue ) @@ -23,7 +23,7 @@ class RealtimeComponentTests: OptimoveTestCase { let event2 = FixtureOptistreamEvent.generateEvent(event: "event2") let event1Expectation = expectation(description: "\(event1.event) was not generated") let event2Expectation = expectation(description: "\(event2.event) was not generated") - networking.assetEventsFunction = { events, _ in + dispatcher.assetEventsFunction = { events, _ in events.forEach { event in switch event.event { case event1.event: @@ -53,13 +53,13 @@ class RealtimeComponentTests: OptimoveTestCase { realtime = RealTime( configuration: configuration.realtime, storage: storage, - networking: networking, + dispatcher: dispatcher, queue: queue ) let event1 = FixtureOptistreamEvent.generateEvent(event: "event1") let event1Expectation = expectation(description: "\(event1.event) was not generated") event1Expectation.isInverted.toggle() - networking.assetEventsFunction = { events, _ in + dispatcher.assetEventsFunction = { events, _ in events.forEach { event in switch event.event { case event1.event: diff --git a/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift b/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift index 3510d380..e0f18679 100644 --- a/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift +++ b/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift @@ -80,7 +80,8 @@ struct MockOptimoveConfig { tenantInfo: nil, optimobileConfig: nil, preferenceCenterConfig: nil, - embeddedMessagingConfig: embeddedMessagingConfig + embeddedMessagingConfig: embeddedMessagingConfig, + authTokenProvider: nil ) } } diff --git a/Shared/Sources/Mocks/OptistreamDispatcherMock.swift b/Shared/Sources/Mocks/OptistreamDispatcherMock.swift new file mode 100644 index 00000000..11ca9fb2 --- /dev/null +++ b/Shared/Sources/Mocks/OptistreamDispatcherMock.swift @@ -0,0 +1,28 @@ +// Copyright © 2026 Optimove. All rights reserved. + +import OptimoveCore + +public final class OptistreamDispatcherMock: OptistreamDispatcher { + public init() {} + + /// Called with the events batch. Use the completion to report success/failure. + /// The mock treats the entire batch as a single group (no customer splitting). + public var assetEventsFunction: ((_ events: [OptistreamEvent], _ completion: (Result) -> Void) -> Void)? + + public func sendBatch( + events: [OptistreamEvent], + path _: String?, + onGroupResult: @escaping ([OptistreamEvent], Result) -> Void, + completion: @escaping () -> Void + ) { + if let handler = assetEventsFunction { + handler(events) { result in + onGroupResult(events, result) + completion() + } + } else { + onGroupResult(events, .success(())) + completion() + } + } +} diff --git a/Shared/Sources/Mocks/OptistreamNetworkingMock.swift b/Shared/Sources/Mocks/OptistreamNetworkingMock.swift index 64d1b169..70692e85 100644 --- a/Shared/Sources/Mocks/OptistreamNetworkingMock.swift +++ b/Shared/Sources/Mocks/OptistreamNetworkingMock.swift @@ -7,11 +7,7 @@ public final class OptistreamNetworkingMock: OptistreamNetworking { public var assetEventsFunction: ((_ events: [OptistreamEvent], _ completion: (Result) -> Void) -> Void)? - public func send(events: [OptistreamEvent], completion: @escaping (Result) -> Void) { - assetEventsFunction?(events, completion) - } - - public func send(events: [OptistreamEvent], path _: String, completion: @escaping (Result) -> Void) { + public func send(events: [OptistreamEvent], path _: String?, jwt _: String?, completion: @escaping (Result) -> Void) { assetEventsFunction?(events, completion) } } From cbf436414477d74bde0009ffcd45eb357527f3ee Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Mon, 16 Feb 2026 16:59:29 -0500 Subject: [PATCH 04/16] Added auth to optimobile --- OptimobileShared/OptimobileHelper.swift | 6 +++ .../Classes/Optimobile/AnalyticsHelper.swift | 52 ++++++++++++++----- .../Optimobile/InApp/InAppManager.swift | 2 +- .../Optimobile/Network/KSHttpClient.swift | 33 ++++++++++-- .../Optimobile/Network/NetworkFactory.swift | 8 ++- .../Optimobile/Optimobile+DeepLinking.swift | 2 +- .../Classes/Optimobile/Optimobile.swift | 10 ++-- .../Optimobile/AnalyticsHelperTests.swift | 4 +- 8 files changed, 90 insertions(+), 27 deletions(-) diff --git a/OptimobileShared/OptimobileHelper.swift b/OptimobileShared/OptimobileHelper.swift index 11594019..82f1cea6 100644 --- a/OptimobileShared/OptimobileHelper.swift +++ b/OptimobileShared/OptimobileHelper.swift @@ -39,6 +39,12 @@ enum OptimobileHelper { return OptimobileHelper.installId } + static var associatedUserId: String? { + userIdLock.wait() + defer { userIdLock.signal() } + return KeyValPersistenceHelper.object(forKey: OptimobileUserDefaultsKey.USER_ID.rawValue) as? String + } + static func getBadgeFromUserInfo(userInfo: [AnyHashable: Any]) -> NSNumber? { let custom = userInfo["custom"] as? [AnyHashable: Any] let aps = userInfo["aps"] as? [AnyHashable: Any] diff --git a/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift b/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift index e907d206..06430448 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift @@ -170,7 +170,7 @@ final class AnalyticsHelper { private func syncEventsImpl(context: NSManagedObjectContext?, _ onSyncComplete: SyncCompletedBlock? = nil) { context?.performAndWait { - let results = fetchEventsBatch(context) + let results = fetchNextUserEventsBatch(context) if results.count == 0 { onSyncComplete?(nil) @@ -179,7 +179,8 @@ final class AnalyticsHelper { removeAppDatabase() } } else if results.count > 0 { - syncEventsBatch(context, events: results, onSyncComplete) + let batchUserId = results.first?.userIdentifier + syncEventsBatch(context, batchUserId: batchUserId, events: results, onSyncComplete) return } } @@ -210,7 +211,7 @@ final class AnalyticsHelper { migrationAnalyticsContext = nil } - private func syncEventsBatch(_ context: NSManagedObjectContext?, events: [KSEventModel], _ onSyncComplete: SyncCompletedBlock? = nil) { + private func syncEventsBatch(_ context: NSManagedObjectContext?, batchUserId: String?, events: [KSEventModel], _ onSyncComplete: SyncCompletedBlock? = nil) { var data = [] as [[String: Any?]] var eventIds = [] as [NSManagedObjectID] @@ -232,10 +233,16 @@ final class AnalyticsHelper { let path = "/v1/app-installs/\(OptimobileHelper.installId)/events" + // This is a workaround for not being able to differentiate between visitor and user events + // Visitor events are stamped with the installId as their userIdentifier + // Pass nil for visitor batches so the HTTP client skips JWT resolution. + let isVisitorBatch = (batchUserId == OptimobileHelper.installId) + let authUserId: String? = isVisitorBatch ? nil : batchUserId + var err : Error? = nil let networkBarrier = DispatchSemaphore(value: 0) - eventsHttpClient.sendRequest(.POST, toPath: path, data: data, onSuccess: { _, _ in + eventsHttpClient.sendRequest(.POST, toPath: path, data: data, authUserId: authUserId, onSuccess: { _, _ in networkBarrier.signal() }) { _, error, _ in err = error @@ -275,20 +282,39 @@ final class AnalyticsHelper { return err } - private func fetchEventsBatch(_ context: NSManagedObjectContext?) -> [KSEventModel] { + /// Fetch the next batch of events for a single user. + /// + /// Peeks at the oldest queued event to determine which `userIdentifier` to drain next, + /// then returns up to 100 events for that user (oldest first). + /// Events for other users stay in the queue and will be picked up on the next sync cycle. + /// This ensures each HTTP request carries events for a single user + private func fetchNextUserEventsBatch(_ context: NSManagedObjectContext?) -> [KSEventModel] { guard let context = context else { return [] } - let request = NSFetchRequest(entityName: "Event") - request.returnsObjectsAsFaults = false - request.sortDescriptors = [NSSortDescriptor(key: "happenedAt", ascending: true)] - request.fetchLimit = 100 - request.includesPendingChanges = false - do { - let results = try context.fetch(request) - return results + // Step 1: Peek at the oldest event to determine which userIdentifier to batch. + let peekRequest = NSFetchRequest(entityName: "Event") + peekRequest.returnsObjectsAsFaults = false + peekRequest.sortDescriptors = [NSSortDescriptor(key: "happenedAt", ascending: true)] + peekRequest.fetchLimit = 1 + peekRequest.includesPendingChanges = false + + let oldest = try context.fetch(peekRequest) + guard let firstEvent = oldest.first else { + return [] + } + + // Step 2: Fetch up to 100 events with the same userIdentifier, oldest first. + let batchRequest = NSFetchRequest(entityName: "Event") + batchRequest.returnsObjectsAsFaults = false + batchRequest.sortDescriptors = [NSSortDescriptor(key: "happenedAt", ascending: true)] + batchRequest.fetchLimit = 100 + batchRequest.includesPendingChanges = false + batchRequest.predicate = NSPredicate(format: "userIdentifier == %@", firstEvent.userIdentifier) + + return try context.fetch(batchRequest) } catch { print("Failed to fetch events batch: " + error.localizedDescription) return [] diff --git a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppManager.swift b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppManager.swift index ee8d1c5e..48922898 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppManager.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppManager.swift @@ -279,7 +279,7 @@ class InAppManager { let encodedIdentifier = KSHttpUtil.urlEncode(OptimobileHelper.currentUserIdentifier) let path = "/v1/users/\(encodedIdentifier!)/messages\(after)" - self.httpClient.sendRequest(.GET, toPath: path, data: nil, onSuccess: { _, decodedBody in + self.httpClient.sendRequest(.GET, toPath: path, data: nil, authUserId: OptimobileHelper.associatedUserId, onSuccess: { _, decodedBody in defer { UserDefaults.standard.set(Date(), forKey: OptimobileUserDefaultsKey.IN_APP_LAST_SYNCED_AT.rawValue) syncBarrier.signal() diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift b/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift index da568456..58a1641c 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Optimove. All rights reserved. import Foundation +import OptimoveCore enum KSHttpError: Error { case responseCastingError @@ -25,7 +26,10 @@ enum KSHttpMethod: String { protocol KSHttpClient { func invalidateSessionCancellingTasks(_ cancel: Bool) - func sendRequest(_ method: KSHttpMethod, toPath path: String, data: Any?, onSuccess: @escaping KSHttpSuccessBlock, onFailure: @escaping KSHttpFailureBlock) + /// Send an HTTP request. + /// - `authUserId == nil`: no JWT is attached (visitor / unauthenticated requests). + /// - `authUserId == `: a JWT is resolved for that user and attached. + func sendRequest(_ method: KSHttpMethod, toPath path: String, data: Any?, authUserId: String?, onSuccess: @escaping KSHttpSuccessBlock, onFailure: @escaping KSHttpFailureBlock) } final class KSHttpClientImpl: KSHttpClient { @@ -36,6 +40,7 @@ final class KSHttpClientImpl: KSHttpClient { private let requestFormat: KSHttpDataFormat private let responseFormat: KSHttpDataFormat private let authorization: HttpAuthorizationProtocol + private let authManager: AuthManager? // MARK: Initializers & Configs @@ -45,13 +50,15 @@ final class KSHttpClientImpl: KSHttpClient { requestFormat: KSHttpDataFormat, responseFormat: KSHttpDataFormat, authorization: HttpAuthorizationProtocol, - additionalHeaders: [AnyHashable: Any]? = nil + additionalHeaders: [AnyHashable: Any]? = nil, + authManager: AuthManager? = nil ) { self.authorization = authorization self.serviceType = serviceType self.urlBuilder = urlBuilder self.requestFormat = requestFormat self.responseFormat = responseFormat + self.authManager = authManager let config = URLSessionConfiguration.ephemeral @@ -77,14 +84,32 @@ final class KSHttpClientImpl: KSHttpClient { // MARK: HTTP Methods - func sendRequest(_ method: KSHttpMethod, toPath path: String, data: Any?, onSuccess: @escaping KSHttpSuccessBlock, onFailure: @escaping KSHttpFailureBlock) { + func sendRequest(_ method: KSHttpMethod, toPath path: String, data: Any?, authUserId: String?, onSuccess: @escaping KSHttpSuccessBlock, onFailure: @escaping KSHttpFailureBlock) { do { var request = try buildRequest(for: path, method: method, body: data) let headers = try authorization.getAuthorizationHeader(strategy: .basic) request.allHTTPHeaderFields = request.allHTTPHeaderFields?.merging(headers) { _, new in new } ?? headers - sendRequest(request: request, onSuccess: onSuccess, onFailure: onFailure) + // Signal that this SDK version supports auth. + request.addValue("1", forHTTPHeaderField: "X-Optimove-Auth-Capable") + + guard let authManager = authManager, let userId = authUserId else { + sendRequest(request: request, onSuccess: onSuccess, onFailure: onFailure) + return + } + + authManager.getToken(userId: userId) { [self] result in + switch result { + case .success(let jwt): + request.addValue(jwt, forHTTPHeaderField: "X-User-JWT") + self.sendRequest(request: request, onSuccess: onSuccess, onFailure: onFailure) + case .failure(let error): + // Fail-closed: drop the request (matches Web SDK behavior) + Logger.error("Auth token failed for Optimobile request: \(error.localizedDescription). Dropping request.") + onFailure(nil, error, nil) + } + } } catch { onFailure(nil, error, nil) } diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Network/NetworkFactory.swift b/OptimoveSDK/Sources/Classes/Optimobile/Network/NetworkFactory.swift index b9a90697..b2121f9c 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/Network/NetworkFactory.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/Network/NetworkFactory.swift @@ -1,14 +1,17 @@ // Copyright © 2023 Optimove. All rights reserved. import Foundation +import OptimoveCore final class NetworkFactory { var urlBuilder: UrlBuilder var authorization: HttpAuthorizationProtocol + var authManager: AuthManager? - init(urlBuilder: UrlBuilder, authorization: HttpAuthorizationProtocol) { + init(urlBuilder: UrlBuilder, authorization: HttpAuthorizationProtocol, authManager: AuthManager? = nil) { self.urlBuilder = urlBuilder self.authorization = authorization + self.authManager = authManager } func build(for service: UrlBuilder.Service) -> KSHttpClient { @@ -17,7 +20,8 @@ final class NetworkFactory { urlBuilder: urlBuilder, requestFormat: .json, responseFormat: .json, - authorization: authorization + authorization: authorization, + authManager: authManager ) } } diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Optimobile+DeepLinking.swift b/OptimoveSDK/Sources/Classes/Optimobile/Optimobile+DeepLinking.swift index 2a22fcc0..504df246 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/Optimobile+DeepLinking.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/Optimobile+DeepLinking.swift @@ -120,7 +120,7 @@ final class DeepLinkHelper { path = path + "&" + query } - httpClient.sendRequest(.GET, toPath: path, data: nil, onSuccess: { res, data in + httpClient.sendRequest(.GET, toPath: path, data: nil, authUserId: nil, onSuccess: { res, data in switch res?.statusCode { case 200: guard let dictionary = data as? [AnyHashable: Any], diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Optimobile.swift b/OptimoveSDK/Sources/Classes/Optimobile/Optimobile.swift index c15da995..95adcba8 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/Optimobile.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/Optimobile.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Optimove. All rights reserved. import Foundation +import OptimoveCore import UserNotifications public typealias InAppDeepLinkHandlerBlock = (InAppButtonPress) -> Void @@ -101,7 +102,7 @@ final class Optimobile { /** Initialize the Optimobile SDK. */ - static func initialize(config optimoveConfig: OptimoveConfig, initialVisitorId: String, initialUserId: String?) throws { + static func initialize(config optimoveConfig: OptimoveConfig, initialVisitorId: String, initialUserId: String?, authManager: AuthManager? = nil) throws { if instance !== nil, optimoveConfig.features.contains(.delayedConfiguration) { try completeDelayedConfiguration(config: optimoveConfig.optimobileConfig!) return @@ -116,7 +117,7 @@ final class Optimobile { throw Error.configurationIsMissing } - instance = Optimobile(config: config) + instance = Optimobile(config: config, authManager: authManager) writeDefaultsKeys(config: config, initialVisitorId: initialVisitorId) @@ -196,14 +197,15 @@ final class Optimobile { Optimobile.associateUserWithInstall(userIdentifier: initialUserId!) } - private init(config: OptimobileConfig) { + private init(config: OptimobileConfig, authManager: AuthManager? = nil) { self.config = config let urlBuilder = UrlBuilder(storage: KeyValPersistenceHelper.self) networkFactory = NetworkFactory( urlBuilder: urlBuilder, authorization: AuthorizationMediator(provider: { Optimobile.instance?.credentials - }) + }), + authManager: authManager ) inAppConsentStrategy = config.inAppConsentStrategy diff --git a/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift b/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift index a4f41ad5..526298bb 100644 --- a/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift +++ b/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift @@ -124,7 +124,7 @@ class AnalyticsHelperTests: XCTestCase { class MockKSHttpClient: KSHttpClient { var capturedData: Any? - func sendRequest(_ method: OptimoveSDK.KSHttpMethod, toPath path: String, data: Any?, onSuccess: @escaping OptimoveSDK.KSHttpSuccessBlock, onFailure: @escaping OptimoveSDK.KSHttpFailureBlock) { + func sendRequest(_ method: OptimoveSDK.KSHttpMethod, toPath path: String, data: Any?, authUserId: String?, onSuccess: @escaping OptimoveSDK.KSHttpSuccessBlock, onFailure: @escaping OptimoveSDK.KSHttpFailureBlock) { capturedData = data onSuccess(nil, nil) } @@ -139,7 +139,7 @@ class MockKSHttpClientSingleFailure: KSHttpClient { var capturedData: Any? var failed = false - func sendRequest(_ method: OptimoveSDK.KSHttpMethod, toPath path: String, data: Any?, onSuccess: @escaping OptimoveSDK.KSHttpSuccessBlock, onFailure: @escaping OptimoveSDK.KSHttpFailureBlock) { + func sendRequest(_ method: OptimoveSDK.KSHttpMethod, toPath path: String, data: Any?, authUserId: String?, onSuccess: @escaping OptimoveSDK.KSHttpSuccessBlock, onFailure: @escaping OptimoveSDK.KSHttpFailureBlock) { if !failed { onFailure(nil, NSError(domain: "domain", code: 404), nil) failed = true From 660123f2bb03518a64555a75d86a2faef6126958 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Tue, 17 Feb 2026 09:13:57 -0500 Subject: [PATCH 05/16] Added more tests to auth --- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Tests/Sources/Auth/AuthManagerTests.swift | 87 +++++ .../Tests/Sources/NetworkClientTests.swift | 44 ++- .../OptistreamDispatcherTests.swift | 317 ++++++++++++++++++ .../OptistreamNetworkingTests.swift | 136 ++++++++ .../Network/EmbeddedMessagingTests.swift | 210 +++++++++++- .../GlobalConfigurationDownloaderTests.swift | 2 +- .../TenantConfigurationDownloaderTests.swift | 2 +- .../Optimobile/AnalyticsHelperTests.swift | 63 ++++ .../Optimobile/KSHttpClientTests.swift | 173 ++++++++++ Package.resolved | 4 +- 11 files changed, 1033 insertions(+), 9 deletions(-) create mode 100644 OptimoveCore/Tests/Sources/Auth/AuthManagerTests.swift create mode 100644 OptimoveCore/Tests/Sources/Optistream/OptistreamDispatcherTests.swift create mode 100644 OptimoveCore/Tests/Sources/Optistream/OptistreamNetworkingTests.swift create mode 100644 OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift diff --git a/Optimove.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Optimove.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 102fc929..eb3f9205 100644 --- a/Optimove.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Optimove.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/WeTransfer/Mocker", "state": { "branch": null, - "revision": "4384e015cae4916a6828252467a4437173c7ae17", - "version": "3.0.1" + "revision": "95fa785c751f6bc40c49e112d433c3acf8417a97", + "version": "3.0.2" } } ] diff --git a/OptimoveCore/Tests/Sources/Auth/AuthManagerTests.swift b/OptimoveCore/Tests/Sources/Auth/AuthManagerTests.swift new file mode 100644 index 00000000..394fe7c5 --- /dev/null +++ b/OptimoveCore/Tests/Sources/Auth/AuthManagerTests.swift @@ -0,0 +1,87 @@ +// Copyright © 2026 Optimove. All rights reserved. + +import XCTest +@testable import OptimoveCore + +final class AuthManagerTests: XCTestCase { + + // MARK: - 1.1 getToken calls provider with correct userId + + func test_getToken_callsProviderWithUserId() { + let providerExpectation = expectation(description: "Provider should be called with correct userId") + let authManager = AuthManager { userId, completion in + XCTAssertEqual(userId, "user-123") + providerExpectation.fulfill() + completion("token", nil) + } + + authManager.getToken(userId: "user-123") { _ in } + waitForExpectations(timeout: 1) + } + + // MARK: - 1.2 getToken returns success when provider returns token + + func test_getToken_returnsSuccessWhenProviderReturnsToken() { + let completionExpectation = expectation(description: "Completion should receive .success") + let authManager = AuthManager { _, completion in + completion("jwt-token", nil) + } + + authManager.getToken(userId: "user-123") { result in + switch result { + case .success(let token): + XCTAssertEqual(token, "jwt-token") + case .failure: + XCTFail("Expected success but got failure") + } + completionExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 1.3 getToken returns failure when provider returns error + + func test_getToken_returnsFailureWhenProviderReturnsError() { + let completionExpectation = expectation(description: "Completion should receive .failure") + let someError = NSError(domain: "test", code: 42, userInfo: nil) + let authManager = AuthManager { _, completion in + completion(nil, someError) + } + + authManager.getToken(userId: "user-123") { result in + switch result { + case .success: + XCTFail("Expected failure but got success") + case .failure(let error): + XCTAssertEqual((error as NSError).code, 42) + } + completionExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 1.4 getToken returns tokenFetchFailed when provider returns nil token and nil error + + func test_getToken_returnsTokenFetchFailedWhenProviderReturnsNilTokenAndNilError() { + let completionExpectation = expectation(description: "Completion should receive .failure(tokenFetchFailed)") + let authManager = AuthManager { _, completion in + completion(nil, nil) + } + + authManager.getToken(userId: "user-123") { result in + switch result { + case .success: + XCTFail("Expected failure but got success") + case .failure(let error): + guard let authError = error as? AuthError else { + XCTFail("Expected AuthError but got \(type(of: error))") + completionExpectation.fulfill() + return + } + XCTAssertEqual(authError, AuthError.tokenFetchFailed) + } + completionExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } +} diff --git a/OptimoveCore/Tests/Sources/NetworkClientTests.swift b/OptimoveCore/Tests/Sources/NetworkClientTests.swift index 15ebdd62..984af6b2 100644 --- a/OptimoveCore/Tests/Sources/NetworkClientTests.swift +++ b/OptimoveCore/Tests/Sources/NetworkClientTests.swift @@ -11,7 +11,7 @@ class NetworkClientTests: XCTestCase { Mocker.register( Mock( url: StubVariables.url, - dataType: .json, + contentType: .json, statusCode: 200, data: [.get: Data()] ) @@ -46,7 +46,7 @@ class NetworkClientTests: XCTestCase { Mocker.register( Mock( url: StubVariables.url, - dataType: .json, + contentType: .json, statusCode: 200, data: [.post: Data()] ) @@ -74,4 +74,44 @@ class NetworkClientTests: XCTestCase { // then wait(for: [success], timeout: defaultTimeout) } + + func test_perform_addsXOptimoveAuthCapableHeader() { + // given + let headerExpectation = expectation(description: "X-Optimove-Auth-Capable header should be present") + + var mock = Mock( + url: StubVariables.url, + contentType: .json, + statusCode: 200, + data: [.get: Data()] + ) + mock.onRequestHandler = OnRequestHandler(requestCallback: { urlRequest in + let authCapable = urlRequest.value(forHTTPHeaderField: "X-Optimove-Auth-Capable") + XCTAssertEqual(authCapable, "1", "Every request should include X-Optimove-Auth-Capable: 1") + headerExpectation.fulfill() + }) + Mocker.register(mock) + + // and + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + let client = NetworkClientImpl(configuration: configuration) + + // and + let request = NetworkRequest(method: .get, baseURL: StubVariables.url) + + // when + let success = expectation(description: "Result with success was not generated") + client.perform(request) { result in + switch result { + case .success: + success.fulfill() + case .failure: + XCTFail() + } + } + + // then + wait(for: [headerExpectation, success], timeout: defaultTimeout) + } } diff --git a/OptimoveCore/Tests/Sources/Optistream/OptistreamDispatcherTests.swift b/OptimoveCore/Tests/Sources/Optistream/OptistreamDispatcherTests.swift new file mode 100644 index 00000000..b5eb7808 --- /dev/null +++ b/OptimoveCore/Tests/Sources/Optistream/OptistreamDispatcherTests.swift @@ -0,0 +1,317 @@ +// Copyright © 2026 Optimove. All rights reserved. + +import XCTest +@testable import OptimoveCore + +// MARK: - Spy that records (events, path, jwt) per send call + +final class OptistreamNetworkingSpy: OptistreamNetworking { + struct SendCall { + let events: [OptistreamEvent] + let path: String? + let jwt: String? + } + + var sendCalls: [SendCall] = [] + var sendResult: Result = .success(()) + + func send(events: [OptistreamEvent], path: String?, jwt: String?, completion: @escaping (Result) -> Void) { + sendCalls.append(SendCall(events: events, path: path, jwt: jwt)) + completion(sendResult) + } +} + +// MARK: - Test Event Factory + +private func makeEvent(customer: String?, event: String = "test") -> OptistreamEvent { + return OptistreamEvent( + tenant: 9999, + category: "test", + event: event, + origin: "sdk", + customer: customer, + visitor: "visitor-1", + timestamp: "2026-01-01T00:00:00.000Z", + context: [], + metadata: OptistreamEvent.Metadata( + realtime: true, + firstVisitorDate: 1000, + eventId: UUID().uuidString, + requestId: UUID().uuidString + ) + ) +} + +// MARK: - Tests + +final class OptistreamDispatcherTests: XCTestCase { + + func test_sendBatch_noAuthManager_sendsEntireBatchWithoutJWT() { + let networkingSpy = OptistreamNetworkingSpy() + let dispatcher = OptistreamDispatcherImpl(networking: networkingSpy, authManager: nil) + + let events = [makeEvent(customer: "user-A"), makeEvent(customer: "user-B")] + + let completionExpectation = expectation(description: "completion called") + var groupResults: [(events: [OptistreamEvent], result: Result)] = [] + + dispatcher.sendBatch( + events: events, + path: "testPath", + onGroupResult: { groupEvents, result in + groupResults.append((groupEvents, result)) + }, + completion: { + completionExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + + XCTAssertEqual(networkingSpy.sendCalls.count, 1, "Should send entire batch in one call") + XCTAssertEqual(networkingSpy.sendCalls.first?.events.count, 2) + XCTAssertNil(networkingSpy.sendCalls.first?.jwt, "JWT should be nil when no authManager") + XCTAssertEqual(networkingSpy.sendCalls.first?.path, "testPath") + XCTAssertEqual(groupResults.count, 1, "onGroupResult should be called once") + } + + + func test_sendBatch_authConfigured_singleCustomer_sendsWithJWT() { + let networkingSpy = OptistreamNetworkingSpy() + let authManager = AuthManager { userId, completion in + XCTAssertEqual(userId, "user-A") + completion("jwt-for-A", nil) + } + let dispatcher = OptistreamDispatcherImpl(networking: networkingSpy, authManager: authManager) + + let events = [makeEvent(customer: "user-A"), makeEvent(customer: "user-A")] + + let completionExpectation = expectation(description: "completion called") + + dispatcher.sendBatch( + events: events, + path: nil, + onGroupResult: { _, _ in }, + completion: { + completionExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + + XCTAssertEqual(networkingSpy.sendCalls.count, 1) + XCTAssertEqual(networkingSpy.sendCalls.first?.jwt, "jwt-for-A") + XCTAssertEqual(networkingSpy.sendCalls.first?.events.count, 2) + } + + + func test_sendBatch_authConfigured_anonymousEvents_sendsWithoutJWT() { + let networkingSpy = OptistreamNetworkingSpy() + let authManager = AuthManager { _, completion in + XCTFail("getToken should not be called for anonymous events") + completion(nil, NSError(domain: "test", code: 0)) + } + let dispatcher = OptistreamDispatcherImpl(networking: networkingSpy, authManager: authManager) + + let events = [makeEvent(customer: nil), makeEvent(customer: nil)] + + let completionExpectation = expectation(description: "completion called") + + dispatcher.sendBatch( + events: events, + path: nil, + onGroupResult: { _, _ in }, + completion: { + completionExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + + XCTAssertEqual(networkingSpy.sendCalls.count, 1) + XCTAssertNil(networkingSpy.sendCalls.first?.jwt, "No JWT for anonymous events") + } + + func test_sendBatch_authConfigured_getTokenFails_reportsFailure() { + let networkingSpy = OptistreamNetworkingSpy() + let authManager = AuthManager { _, completion in + completion(nil, NSError(domain: "auth", code: 401)) + } + let dispatcher = OptistreamDispatcherImpl(networking: networkingSpy, authManager: authManager) + + let events = [makeEvent(customer: "user-A")] + + let completionExpectation = expectation(description: "completion called") + var groupResults: [(events: [OptistreamEvent], result: Result)] = [] + + dispatcher.sendBatch( + events: events, + path: nil, + onGroupResult: { groupEvents, result in + groupResults.append((groupEvents, result)) + }, + completion: { + completionExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + + XCTAssertEqual(networkingSpy.sendCalls.count, 0, "Networking should not be called on auth failure") + XCTAssertEqual(groupResults.count, 1) + if case .failure = groupResults.first?.result { + // expected + } else { + XCTFail("Expected failure result for group") + } + } + + func test_sendBatch_authConfigured_mixedCustomers_splitsIntoMultipleGroups() { + let networkingSpy = OptistreamNetworkingSpy() + let authManager = AuthManager { userId, completion in + completion("jwt-for-\(userId)", nil) + } + let dispatcher = OptistreamDispatcherImpl(networking: networkingSpy, authManager: authManager) + + let events = [ + makeEvent(customer: "user-A", event: "e1"), + makeEvent(customer: "user-A", event: "e2"), + makeEvent(customer: "user-B", event: "e3"), + makeEvent(customer: "user-B", event: "e4"), + ] + + let completionExpectation = expectation(description: "completion called") + var groupResults: [(events: [OptistreamEvent], result: Result)] = [] + + dispatcher.sendBatch( + events: events, + path: nil, + onGroupResult: { groupEvents, result in + groupResults.append((groupEvents, result)) + }, + completion: { + completionExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + + XCTAssertEqual(networkingSpy.sendCalls.count, 2, "Should send 2 separate requests") + XCTAssertEqual(groupResults.count, 2, "onGroupResult should be called twice") + + let jwts = Set(networkingSpy.sendCalls.compactMap(\.jwt)) + XCTAssertTrue(jwts.contains("jwt-for-user-A")) + XCTAssertTrue(jwts.contains("jwt-for-user-B")) + + let totalEvents = networkingSpy.sendCalls.reduce(0) { $0 + $1.events.count } + XCTAssertEqual(totalEvents, 4) + } + + func test_sendBatch_authConfigured_mixedAnonymousAndUser_splitsCorrectly() { + let networkingSpy = OptistreamNetworkingSpy() + var getTokenCalls: [String] = [] + let authManager = AuthManager { userId, completion in + getTokenCalls.append(userId) + completion("jwt-for-\(userId)", nil) + } + let dispatcher = OptistreamDispatcherImpl(networking: networkingSpy, authManager: authManager) + + let events = [ + makeEvent(customer: nil, event: "anon1"), + makeEvent(customer: nil, event: "anon2"), + makeEvent(customer: "user-A", event: "e1"), + makeEvent(customer: "user-A", event: "e2"), + ] + + let completionExpectation = expectation(description: "completion called") + + dispatcher.sendBatch( + events: events, + path: nil, + onGroupResult: { _, _ in }, + completion: { + completionExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + + XCTAssertEqual(networkingSpy.sendCalls.count, 2, "Should send 2 groups") + + let anonCall = networkingSpy.sendCalls.first(where: { $0.jwt == nil }) + XCTAssertNotNil(anonCall, "Anonymous group should have nil JWT") + XCTAssertEqual(anonCall?.events.count, 2) + + let userCall = networkingSpy.sendCalls.first(where: { $0.jwt == "jwt-for-user-A" }) + XCTAssertNotNil(userCall, "User group should have JWT") + XCTAssertEqual(userCall?.events.count, 2) + + XCTAssertEqual(getTokenCalls, ["user-A"], "getToken should only be called for user-A, not for anonymous") + } + + func test_sendBatch_authConfigured_multipleGroups_processesSequentially() { + let networkingSpy = OptistreamNetworkingSpy() + let authManager = AuthManager { userId, completion in + completion("jwt-for-\(userId)", nil) + } + let dispatcher = OptistreamDispatcherImpl(networking: networkingSpy, authManager: authManager) + + let events = [ + makeEvent(customer: "user-A"), + makeEvent(customer: "user-B"), + makeEvent(customer: "user-C"), + ] + + let completionExpectation = expectation(description: "completion called") + var groupResultOrder: [String] = [] + + dispatcher.sendBatch( + events: events, + path: nil, + onGroupResult: { groupEvents, _ in + let customer = groupEvents.first?.customer ?? "anon" + groupResultOrder.append(customer) + }, + completion: { + completionExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + + XCTAssertEqual(groupResultOrder.count, 3, "Should have 3 group results") + XCTAssertEqual(networkingSpy.sendCalls.count, 3, "Should have 3 send calls") + } + + func test_sendBatch_authConfigured_singleCustomer_callsOnGroupResultAndCompletion() { + let networkingSpy = OptistreamNetworkingSpy() + let authManager = AuthManager { _, completion in + completion("jwt-123", nil) + } + let dispatcher = OptistreamDispatcherImpl(networking: networkingSpy, authManager: authManager) + + let events = [makeEvent(customer: "user-A"), makeEvent(customer: "user-A")] + + let completionExpectation = expectation(description: "completion called") + let groupResultExpectation = expectation(description: "onGroupResult called") + var receivedGroupEvents: [OptistreamEvent]? + + dispatcher.sendBatch( + events: events, + path: nil, + onGroupResult: { groupEvents, result in + receivedGroupEvents = groupEvents + if case .success = result { + groupResultExpectation.fulfill() + } + }, + completion: { + completionExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + + XCTAssertEqual(receivedGroupEvents?.count, 2, "onGroupResult should receive the correct events") + } +} diff --git a/OptimoveCore/Tests/Sources/Optistream/OptistreamNetworkingTests.swift b/OptimoveCore/Tests/Sources/Optistream/OptistreamNetworkingTests.swift new file mode 100644 index 00000000..394a2cea --- /dev/null +++ b/OptimoveCore/Tests/Sources/Optistream/OptistreamNetworkingTests.swift @@ -0,0 +1,136 @@ +// Copyright © 2026 Optimove. All rights reserved. + +import XCTest +@testable import OptimoveCore + +// MARK: - NetworkClient spy that captures requests + +private final class NetworkClientSpy: NetworkClient { + var lastRequest: NetworkRequest? + var mockResult: Result, NetworkError>? + + func perform(_ request: NetworkRequest, _ completion: @escaping NetworkServiceCompletion) { + lastRequest = request + if let result = mockResult { + completion(result) + } + } +} + +// MARK: - Test event factory + +private func makeEvent(customer: String? = nil) -> OptistreamEvent { + return OptistreamEvent( + tenant: 9999, + category: "test", + event: "test_event", + origin: "sdk", + customer: customer, + visitor: "visitor-1", + timestamp: "2026-01-01T00:00:00.000Z", + context: [], + metadata: OptistreamEvent.Metadata( + realtime: false, + firstVisitorDate: 1000, + eventId: UUID().uuidString, + requestId: UUID().uuidString + ) + ) +} + +// MARK: - Test-only helper to create NetworkResponse (internal init is accessible via @testable) + +private func makeSuccessResponse() -> Result, NetworkError> { + return .success(NetworkResponse(statusCode: 200, body: nil)) +} + +// MARK: - Tests + +final class OptistreamNetworkingTests: XCTestCase { + + private var networkClientSpy: NetworkClientSpy! + var networking: OptistreamNetworkingImpl! + let endpoint = URL(string: "https://example.com/optistream")! + + override func setUp() { + super.setUp() + networkClientSpy = NetworkClientSpy() + networking = OptistreamNetworkingImpl(networkClient: networkClientSpy, endpoint: endpoint) + } + + func test_send_withJWT_includesXUserJWTHeader() { + let completionExpectation = expectation(description: "completion called") + networkClientSpy.mockResult = makeSuccessResponse() + + networking.send(events: [makeEvent()], path: nil, jwt: "my-jwt") { _ in + completionExpectation.fulfill() + } + + waitForExpectations(timeout: 1) + + let headers = networkClientSpy.lastRequest?.headers + let jwtHeader = headers?.first(where: { $0.field == "X-User-JWT" }) + XCTAssertNotNil(jwtHeader, "Request should include X-User-JWT header") + XCTAssertEqual(jwtHeader?.value, "my-jwt") + } + + func test_send_withoutJWT_noUserJWTHeader() { + let completionExpectation = expectation(description: "completion called") + networkClientSpy.mockResult = makeSuccessResponse() + + networking.send(events: [makeEvent()], path: nil, jwt: nil) { _ in + completionExpectation.fulfill() + } + + waitForExpectations(timeout: 1) + + let headers = networkClientSpy.lastRequest?.headers ?? [] + let jwtHeader = headers.first(where: { $0.field == "X-User-JWT" }) + XCTAssertNil(jwtHeader, "Request should NOT include X-User-JWT header when jwt is nil") + } + + func test_send_success_callsCompletionWithSuccess() { + let completionExpectation = expectation(description: "completion called with success") + networkClientSpy.mockResult = makeSuccessResponse() + + networking.send(events: [makeEvent()], path: nil, jwt: nil) { result in + if case .success = result { + completionExpectation.fulfill() + } else { + XCTFail("Expected success") + } + } + + waitForExpectations(timeout: 1) + } + + func test_send_requestInvalid_callsCompletionWithSuccess() { + let completionExpectation = expectation(description: "completion called with success on 4xx") + networkClientSpy.mockResult = .failure(.requestInvalid(nil)) + + networking.send(events: [makeEvent()], path: nil, jwt: nil) { result in + if case .success = result { + completionExpectation.fulfill() + } else { + XCTFail("Expected success on requestInvalid (4xx should still prune events)") + } + } + + waitForExpectations(timeout: 1) + } + + func test_send_networkError_callsCompletionWithFailure() { + let completionExpectation = expectation(description: "completion called with failure") + networkClientSpy.mockResult = .failure(.requestFailed) + + networking.send(events: [makeEvent()], path: nil, jwt: nil) { result in + if case .failure = result { + completionExpectation.fulfill() + } else { + XCTFail("Expected failure on network error") + } + } + + waitForExpectations(timeout: 1) + } +} diff --git a/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift b/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift index e0f18679..6db07367 100644 --- a/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift +++ b/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift @@ -380,6 +380,214 @@ final class EmbeddedMessagingTests: XCTestCase { wait(for: [expectation], timeout: 2) } - +} + +// MARK: - Auth-specific Tests + +final class EmbeddedMessagingAuthTests: XCTestCase { + + var mockStorage: MockOptimoveStorage! + var mockConfig: EmbeddedMessagingConfig! + var mockNetworkClient: MockNetworkClient! + + override func setUp() { + super.setUp() + + mockStorage = MockOptimoveStorage() + mockConfig = EmbeddedMessagingConfig(region: "eu", tenantId: 456, brandId: "123") + mockNetworkClient = MockNetworkClient() + + mockStorage[.customerID] = "adam_b@optimove.com" + mockStorage[.visitorID] = "Optimove" + + let mockOptimoveConfig = MockOptimoveConfig(embeddedMessagingConfig: mockConfig).config + Optimove.initialize(with: mockOptimoveConfig) + } + + private func makeTestMessage() -> EmbeddedMessage { + let createdAt = Date(timeIntervalSince1970: 1752663497311 / 1000) + let updatedAt = Date(timeIntervalSince1970: 1752663497311 / 1000) + + return EmbeddedMessage( + customerId: "adam_b@optimove.com", + isVisitor: false, + templateId: 1, + title: "Test Title", + content: "Test content", + media: nil, + readAt: nil, + url: nil, + engagementId: "eng123", + payload: ["key": AnyCodable("string")], + campaignKind: 1, + executionDateTime: "2025-01-01T12:00:00Z", + messageLayoutType: nil, + expiryDate: nil, + containerId: "test-container", + id: "test-id", + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: nil + ) + } + + func test_getMessagesAsync_noAuthManager_sendsRequestWithoutJWT() throws { + let service = EmbeddedMessagesService(storage: mockStorage, networkClient: mockNetworkClient, authManager: nil) + + let apiResponse = EmbeddedMessagingAPIResponse(containers: ["test-container": [makeTestMessage()]]) + let jsonData = try JSONEncoder().encode(apiResponse) + let mockNetworkResponse = NetworkResponse.testMock(statusCode: 200, body: jsonData) + mockNetworkClient.mockResponse = .success(mockNetworkResponse) + + let exp = expectation(description: "getMessagesAsync completes") + + service.getMessagesAsync { result in + switch result { + case .successMessages: + let headers = self.mockNetworkClient.lastRequest?.headers ?? [] + let jwtHeader = headers.first(where: { $0.field == "X-User-JWT" }) + XCTAssertNil(jwtHeader, "Should NOT include X-User-JWT header when no authManager") + default: + XCTFail("Expected successMessages but got \(result)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 2) + } + + func test_getMessagesAsync_authConfigured_success_includesJWT() throws { + let authManager = AuthManager { userId, completion in + XCTAssertEqual(userId, "adam_b@optimove.com") + completion("jwt-123", nil) + } + let service = EmbeddedMessagesService(storage: mockStorage, networkClient: mockNetworkClient, authManager: authManager) + + let apiResponse = EmbeddedMessagingAPIResponse(containers: ["test-container": [makeTestMessage()]]) + let jsonData = try JSONEncoder().encode(apiResponse) + let mockNetworkResponse = NetworkResponse.testMock(statusCode: 200, body: jsonData) + mockNetworkClient.mockResponse = .success(mockNetworkResponse) + let exp = expectation(description: "getMessagesAsync with JWT completes") + + service.getMessagesAsync { result in + switch result { + case .successMessages: + let headers = self.mockNetworkClient.lastRequest?.headers ?? [] + let jwtHeader = headers.first(where: { $0.field == "X-User-JWT" }) + XCTAssertNotNil(jwtHeader, "Should include X-User-JWT header") + XCTAssertEqual(jwtHeader?.value, "jwt-123") + default: + XCTFail("Expected successMessages but got \(result)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 2) + } + + func test_getMessagesAsync_authConfigured_getTokenFails_returnsError() { + let authManager = AuthManager { _, completion in + completion(nil, NSError(domain: "auth", code: 401)) + } + let service = EmbeddedMessagesService(storage: mockStorage, networkClient: mockNetworkClient, authManager: authManager) + + let exp = expectation(description: "getMessagesAsync fails when auth fails") + + service.getMessagesAsync { result in + switch result { + case .error: + // Fail-closed: auth failure → error returned to caller + break + default: + XCTFail("Expected error but got \(result)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 2) + + XCTAssertNil(mockNetworkClient.lastRequest, "No network request should be made when auth fails") + } + + func test_deleteMessagesAsync_authConfigured_includesJWT() { + let authManager = AuthManager { _, completion in + completion("jwt-delete-456", nil) + } + let service = EmbeddedMessagesService(storage: mockStorage, networkClient: mockNetworkClient, authManager: authManager) + + let mockNetworkResponse = NetworkResponse.testMock(statusCode: 200, body: nil) + mockNetworkClient.mockResponse = .success(mockNetworkResponse) + + let exp = expectation(description: "deleteMessagesAsync with JWT completes") + + service.deleteMessagesAsync(message: makeTestMessage()) { result in + switch result { + case .success: + let headers = self.mockNetworkClient.lastRequest?.headers ?? [] + let jwtHeader = headers.first(where: { $0.field == "X-User-JWT" }) + XCTAssertNotNil(jwtHeader, "Should include X-User-JWT header for delete") + XCTAssertEqual(jwtHeader?.value, "jwt-delete-456") + default: + XCTFail("Expected success but got \(result)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 2) + } + + func test_setAsReadAsync_authConfigured_includesJWT() { + let authManager = AuthManager { _, completion in + completion("jwt-read-789", nil) + } + let service = EmbeddedMessagesService(storage: mockStorage, networkClient: mockNetworkClient, authManager: authManager) + + let mockNetworkResponse = NetworkResponse.testMock(statusCode: 200, body: nil) + mockNetworkClient.mockResponse = .success(mockNetworkResponse) + + let exp = expectation(description: "setAsReadAsync with JWT completes") + + service.setAsReadAsync(message: makeTestMessage(), isRead: true) { result in + switch result { + case .success: + let headers = self.mockNetworkClient.lastRequest?.headers ?? [] + let jwtHeader = headers.first(where: { $0.field == "X-User-JWT" }) + XCTAssertNotNil(jwtHeader, "Should include X-User-JWT header for setAsRead") + XCTAssertEqual(jwtHeader?.value, "jwt-read-789") + default: + XCTFail("Expected success but got \(result)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 2) + } + + func test_reportClickMetricAsync_authConfigured_includesJWT() { + let authManager = AuthManager { _, completion in + completion("jwt-click-101", nil) + } + let service = EmbeddedMessagesService(storage: mockStorage, networkClient: mockNetworkClient, authManager: authManager) + + let mockNetworkResponse = NetworkResponse.testMock(statusCode: 200, body: nil) + mockNetworkClient.mockResponse = .success(mockNetworkResponse) + + let exp = expectation(description: "reportClickMetricAsync with JWT completes") + + service.reportClickMetricAsync(message: makeTestMessage()) { result in + switch result { + case .success: + let headers = self.mockNetworkClient.lastRequest?.headers ?? [] + let jwtHeader = headers.first(where: { $0.field == "X-User-JWT" }) + XCTAssertNotNil(jwtHeader, "Should include X-User-JWT header for reportClick") + XCTAssertEqual(jwtHeader?.value, "jwt-click-101") + default: + XCTFail("Expected success but got \(result)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 2) + } } diff --git a/OptimoveSDK/Tests/Sources/Operations/GlobalConfigurationDownloaderTests.swift b/OptimoveSDK/Tests/Sources/Operations/GlobalConfigurationDownloaderTests.swift index becc3a2f..d0b85af1 100644 --- a/OptimoveSDK/Tests/Sources/Operations/GlobalConfigurationDownloaderTests.swift +++ b/OptimoveSDK/Tests/Sources/Operations/GlobalConfigurationDownloaderTests.swift @@ -29,7 +29,7 @@ class GlobalConfigurationDownloaderTests: XCTestCase { Mocker.register( Mock( url: Endpoints.Remote.GlobalConfig.url, - dataType: .json, + contentType: .json, statusCode: 200, data: [.get: try! JSONEncoder().encode(expectedConfig)] ) diff --git a/OptimoveSDK/Tests/Sources/Operations/TenantConfigurationDownloaderTests.swift b/OptimoveSDK/Tests/Sources/Operations/TenantConfigurationDownloaderTests.swift index 4d823f94..5d5102bc 100644 --- a/OptimoveSDK/Tests/Sources/Operations/TenantConfigurationDownloaderTests.swift +++ b/OptimoveSDK/Tests/Sources/Operations/TenantConfigurationDownloaderTests.swift @@ -41,7 +41,7 @@ class TenantConfigurationDownloaderTests: XCTestCase { try Mocker.register( Mock( url: expectedURL, - dataType: .json, + contentType: .json, statusCode: 200, data: [.get: JSONEncoder().encode(expectedConfig)] ) diff --git a/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift b/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift index 526298bb..a405ed5a 100644 --- a/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift +++ b/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift @@ -123,9 +123,13 @@ class AnalyticsHelperTests: XCTestCase { class MockKSHttpClient: KSHttpClient { var capturedData: Any? + var capturedAuthUserId: String? + var capturedAuthUserIds: [String?] = [] func sendRequest(_ method: OptimoveSDK.KSHttpMethod, toPath path: String, data: Any?, authUserId: String?, onSuccess: @escaping OptimoveSDK.KSHttpSuccessBlock, onFailure: @escaping OptimoveSDK.KSHttpFailureBlock) { capturedData = data + capturedAuthUserId = authUserId + capturedAuthUserIds.append(authUserId) onSuccess(nil, nil) } @@ -135,6 +139,65 @@ class MockKSHttpClient: KSHttpClient { } +// MARK: - Auth-specific Tests + +class AnalyticsHelperAuthTests: XCTestCase { + + var mockHttpClient: MockKSHttpClient! + var analyticsHelper: AnalyticsHelper! + var longTimeoutInSeconds = 10.0 + + override func setUp() { + super.setUp() + mockHttpClient = MockKSHttpClient() + analyticsHelper = AnalyticsHelper(httpClient: mockHttpClient) + } + + override func tearDown() { + analyticsHelper = nil + mockHttpClient = nil + super.tearDown() + } + + // By default, without calling associateUserWithInstall, currentUserIdentifier == installId. + // The syncEventsBatch code detects this as a visitor batch and passes authUserId: nil. + + func test_syncEventsBatch_defaultVisitorEvents_passesNilAuthUserId() { + let authUserIdExpectation = expectation(description: "authUserId should be nil for visitor events") + + analyticsHelper.trackEvent(eventType: "visitor_event", atTime: Date(), properties: nil, immediateFlush: true) { _ in + if self.mockHttpClient.capturedAuthUserId == nil { + authUserIdExpectation.fulfill() + } + } + + waitForExpectations(timeout: longTimeoutInSeconds, handler: nil) + } + + // After associateUserWithInstall, currentUserIdentifier returns the user's ID. + // Events stamped with that ID should be sent with authUserId set. + + func test_syncEventsBatch_associatedUser_passesAuthUserId() { + let testUserId = "user-123" + + // Associate a user. This changes OptimobileHelper.currentUserIdentifier. + KeyValPersistenceHelper.set(testUserId, forKey: OptimobileUserDefaultsKey.USER_ID.rawValue) + + let authUserIdExpectation = expectation(description: "authUserId should be the user's identifier") + + analyticsHelper.trackEvent(eventType: "user_event", atTime: Date(), properties: nil, immediateFlush: true) { _ in + if self.mockHttpClient.capturedAuthUserId == testUserId { + authUserIdExpectation.fulfill() + } + } + + waitForExpectations(timeout: longTimeoutInSeconds, handler: nil) + + // Cleanup: restore to visitor + KeyValPersistenceHelper.removeObject(forKey: OptimobileUserDefaultsKey.USER_ID.rawValue) + } +} + class MockKSHttpClientSingleFailure: KSHttpClient { var capturedData: Any? var failed = false diff --git a/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift b/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift new file mode 100644 index 00000000..17e840c6 --- /dev/null +++ b/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift @@ -0,0 +1,173 @@ +// Copyright © 2026 Optimove. All rights reserved. + +import XCTest +import OptimoveCore +@testable import OptimoveSDK + +// MARK: - Stub authorization that returns a fixed header + +private class StubAuthorization: HttpAuthorizationProtocol { + func getAuthorizationHeader(strategy: AuthorizationStrategy) throws -> HttpHeader { + return ["Authorization": "Basic dGVzdDp0ZXN0"] + } +} + +// MARK: - Stub URL Builder with runtime URLs + +private class StubUrlBuilder: UrlBuilder { + init() { + super.init( + storage: KeyValPersistenceHelper.self, + runtimeUrlsMap: [ + .events: "https://test-events.example.com", + .crm: "https://test-crm.example.com", + .ddl: "https://test-ddl.example.com", + .iar: "https://test-iar.example.com", + .media: "https://test-media.example.com", + .push: "https://test-push.example.com", + ] + ) + } + + required init(storage: KeyValPersistenceHelper.Type, runtimeUrlsMap: ServiceUrlMap? = nil) { + super.init(storage: storage, runtimeUrlsMap: runtimeUrlsMap) + } +} + +// MARK: - Tests + +final class KSHttpClientTests: XCTestCase { + + func test_sendRequest_authConfigured_getTokenFails_callsOnFailure() { + let authManager = AuthManager { _, completion in + completion(nil, NSError(domain: "auth", code: 401, userInfo: [NSLocalizedDescriptionKey: "Unauthorized"])) + } + + let client = KSHttpClientImpl( + serviceType: .events, + urlBuilder: StubUrlBuilder(), + requestFormat: .json, + responseFormat: .json, + authorization: StubAuthorization(), + authManager: authManager + ) + + let failureExpectation = expectation(description: "onFailure called due to auth token failure") + + client.sendRequest( + .POST, + toPath: "/v1/test", + data: nil, + authUserId: "user-123", + onSuccess: { _, _ in + XCTFail("Expected failure, not success") + }, + onFailure: { response, error, _ in + XCTAssertNil(response, "No HTTP response should exist — request was never sent") + XCTAssertNotNil(error) + XCTAssertEqual((error as NSError?)?.code, 401) + failureExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 2) + } + + func test_sendRequest_authConfigured_success_callsGetTokenWithCorrectUserId() { + let getTokenExpectation = expectation(description: "getToken called with correct userId") + + let authManager = AuthManager { userId, completion in + XCTAssertEqual(userId, "user-456") + getTokenExpectation.fulfill() + completion("jwt-token-xyz", nil) + } + + let client = KSHttpClientImpl( + serviceType: .events, + urlBuilder: StubUrlBuilder(), + requestFormat: .json, + responseFormat: .json, + authorization: StubAuthorization(), + authManager: authManager + ) + + client.sendRequest( + .POST, + toPath: "/v1/test", + data: ["key": "value"], + authUserId: "user-456", + onSuccess: { _, _ in }, + onFailure: { _, _, _ in } + ) + + waitForExpectations(timeout: 2) + client.invalidateSessionCancellingTasks(true) + } + + func test_sendRequest_noAuthManager_doesNotCallGetToken() { + let client = KSHttpClientImpl( + serviceType: .events, + urlBuilder: StubUrlBuilder(), + requestFormat: .json, + responseFormat: .json, + authorization: StubAuthorization(), + authManager: nil + ) + + // With no authManager, the request should proceed directly. + // We can't easily verify the absence of X-User-JWT header here, + // but we verify the code path doesn't crash and proceeds to network. + let responseExpectation = expectation(description: "Request proceeds without auth") + + client.sendRequest( + .GET, + toPath: "/v1/test", + data: nil, + authUserId: nil, + onSuccess: { _, _ in + responseExpectation.fulfill() + }, + onFailure: { _, _, _ in + // Network failure is expected (no real server) — still proves request was sent + responseExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 5) + client.invalidateSessionCancellingTasks(true) + } + + func test_sendRequest_nilAuthUserId_skipsJWT() { + let authManager = AuthManager { _, completion in + XCTFail("getToken should not be called when authUserId is nil") + completion(nil, NSError(domain: "test", code: 0)) + } + + let client = KSHttpClientImpl( + serviceType: .events, + urlBuilder: StubUrlBuilder(), + requestFormat: .json, + responseFormat: .json, + authorization: StubAuthorization(), + authManager: authManager + ) + + let responseExpectation = expectation(description: "Request proceeds without JWT") + + client.sendRequest( + .GET, + toPath: "/v1/test", + data: nil, + authUserId: nil, + onSuccess: { _, _ in + responseExpectation.fulfill() + }, + onFailure: { _, _, _ in + responseExpectation.fulfill() + } + ) + + waitForExpectations(timeout: 5) + client.invalidateSessionCancellingTasks(true) + } +} diff --git a/Package.resolved b/Package.resolved index 102fc929..eb3f9205 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/WeTransfer/Mocker", "state": { "branch": null, - "revision": "4384e015cae4916a6828252467a4437173c7ae17", - "version": "3.0.1" + "revision": "95fa785c751f6bc40c49e112d433c3acf8417a97", + "version": "3.0.2" } } ] From 66c740be25c6dd21c814a1d99e0002c93c2132c4 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Tue, 17 Feb 2026 09:29:03 -0500 Subject: [PATCH 06/16] 6.4.0 version --- CHANGELOG.md | 6 ++++++ OptimoveCore.podspec | 2 +- OptimoveCore/Sources/Classes/Constants/SDKVersion.swift | 2 +- OptimoveNotificationServiceExtension.podspec | 2 +- OptimoveSDK.podspec | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2562eb..20030d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 6.4.0 + +- Add federated JWT authentication support. Use `enableAuth()` on the config builder to supply a token provider. The SDK attaches `X-User-JWT` to all user-identified requests (OptiTrack, RealTime, PreferenceCenter, EmbeddedMessaging, AnalyticsHelper, InAppManager). +- Add `X-Optimove-Auth-Capable: 1` header to all requests to signal auth-capable SDK versions to backends. +- Fix multi-customer event batching: OptiTrack/RealTime now group events by customer identity so each request carries a single valid JWT. AnalyticsHelper fetches events per user to ensure JWT matches the batch. + ## 6.3.0 - Add In-App Message Interceptor API `OptimoveInApp.setInAppMessageInterceptor(_:)` to allow apps to control when in-app messages are shown or suppressed based on custom logic. If no decision is made within the timeout (default 5s), the message is automatically suppressed. diff --git a/OptimoveCore.podspec b/OptimoveCore.podspec index b79594dc..2f9ee9ca 100644 --- a/OptimoveCore.podspec +++ b/OptimoveCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveCore' - s.version = '6.3.0' + s.version = '6.4.0' s.summary = 'Official Optimove SDK for iOS. Core framework.' s.description = 'The core framework is used to share code-base between other Optimove frameworks.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS' diff --git a/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift b/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift index dde4560f..9c2c00d2 100644 --- a/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift +++ b/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift @@ -1,3 +1,3 @@ // Copyright © 2019 Optimove. All rights reserved. -public let SDKVersion = "6.3.0" +public let SDKVersion = "6.4.0" diff --git a/OptimoveNotificationServiceExtension.podspec b/OptimoveNotificationServiceExtension.podspec index 753fb19d..8d06df0b 100644 --- a/OptimoveNotificationServiceExtension.podspec +++ b/OptimoveNotificationServiceExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveNotificationServiceExtension' - s.version = '6.3.0' + s.version = '6.4.0' s.summary = 'Official Optimove SDK for iOS. Notification service extension framework.' s.description = 'The notification service extension is used for handling additional content in push notifications.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS' diff --git a/OptimoveSDK.podspec b/OptimoveSDK.podspec index 0142b777..3e4d9321 100644 --- a/OptimoveSDK.podspec +++ b/OptimoveSDK.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveSDK' - s.version = '6.3.0' + s.version = '6.4.0' s.summary = 'Official Optimove SDK for iOS.' s.description = 'The Optimove SDK framework is used for reporting events and receive push notifications.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS' From 044dc435ca731eddfee341badda44df435878fa6 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Tue, 17 Feb 2026 10:18:57 -0500 Subject: [PATCH 07/16] Comment tweaks --- OptimoveCore/Sources/Classes/Auth/AuthManager.swift | 2 +- .../Sources/Classes/Optistream/OptistreamDispatcher.swift | 2 +- .../Sources/Classes/Optimobile/Network/KSHttpClient.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/OptimoveCore/Sources/Classes/Auth/AuthManager.swift b/OptimoveCore/Sources/Classes/Auth/AuthManager.swift index 3d924e90..3b63830a 100644 --- a/OptimoveCore/Sources/Classes/Auth/AuthManager.swift +++ b/OptimoveCore/Sources/Classes/Auth/AuthManager.swift @@ -28,7 +28,7 @@ public enum AuthError: Error, LocalizedError { /// Manages JWT token retrieval from the client-provided closure. /// - Threading: `getToken` can be called from any queue. The `completion` callback is invoked -/// on whatever queue the client's `AuthTokenProvider` closure dispatches to — callers should +/// on the queue the client's `AuthTokenProvider` closure dispatches to — callers should /// not assume any specific queue and should dispatch to their target queue if needed. public final class AuthManager { private let provider: AuthTokenProvider diff --git a/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift b/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift index db92d1bd..31c45dac 100644 --- a/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift +++ b/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift @@ -54,7 +54,7 @@ public final class OptistreamDispatcherImpl: OptistreamDispatcher { } // Group events by customer identity so each request carries a single JWT. - // Anonymous events (customer nil/empty) are grouped together and sent without JWT. + // Anonymous events (customer nil) are grouped together and sent without JWT. let grouped = Dictionary(grouping: events) { $0.customer ?? "" } let groups = Array(grouped) diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift b/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift index 58a1641c..47676295 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift @@ -105,7 +105,7 @@ final class KSHttpClientImpl: KSHttpClient { request.addValue(jwt, forHTTPHeaderField: "X-User-JWT") self.sendRequest(request: request, onSuccess: onSuccess, onFailure: onFailure) case .failure(let error): - // Fail-closed: drop the request (matches Web SDK behavior) + // Fail-closed: drop the request Logger.error("Auth token failed for Optimobile request: \(error.localizedDescription). Dropping request.") onFailure(nil, error, nil) } From 14edbb17f8c3c7e281a58bc23441c4163f1c0493 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Wed, 18 Feb 2026 15:59:17 -0500 Subject: [PATCH 08/16] Dispatching tweaks --- .../Sources/Classes/Optistream/OptistreamDispatcher.swift | 7 +++---- .../Classes/Optistream/OptistreamEventBuilder.swift | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift b/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift index 31c45dac..0fa50426 100644 --- a/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift +++ b/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift @@ -55,16 +55,15 @@ public final class OptistreamDispatcherImpl: OptistreamDispatcher { // Group events by customer identity so each request carries a single JWT. // Anonymous events (customer nil) are grouped together and sent without JWT. - let grouped = Dictionary(grouping: events) { $0.customer ?? "" } + let grouped = Dictionary(grouping: events) { $0.customer } let groups = Array(grouped) - if grouped.count <= 1 { + if groups.count <= 1, let first = groups.first { // All events belong to the same customer (or all anonymous) — no splitting needed - let customerId = groups.first?.key sendGroup( events: events, path: path, - customerId: customerId, + customerId: first.key, authManager: authManager, onGroupResult: onGroupResult, completion: completion diff --git a/OptimoveCore/Sources/Classes/Optistream/OptistreamEventBuilder.swift b/OptimoveCore/Sources/Classes/Optistream/OptistreamEventBuilder.swift index f8fefb83..5fd278bb 100644 --- a/OptimoveCore/Sources/Classes/Optistream/OptistreamEventBuilder.swift +++ b/OptimoveCore/Sources/Classes/Optistream/OptistreamEventBuilder.swift @@ -28,7 +28,7 @@ public final class OptistreamEventBuilder { category: event.category, event: event.name, origin: Constants.Values.origin, - customer: storage.customerID, + customer: storage.customerID.flatMap { $0.isEmpty ? nil : $0 }, visitor: storage.getVisitorID(), timestamp: Formatter.iso8601withFractionalSeconds.string(from: event.timestamp), context: JSON(event.context), From 8986e4f0344769464e1c23ed99969c30c5581fc0 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Wed, 18 Feb 2026 19:35:28 -0500 Subject: [PATCH 09/16] Aligned the responses of the components to retry on temporary 401s --- .../Classes/NetworkClient/NetworkClient.swift | 4 +- .../Classes/NetworkClient/NetworkError.swift | 14 +++++++ .../Optistream/OptistreamDispatcher.swift | 13 +++++-- .../Optistream/OptistreamNetworking.swift | 15 ++++++++ .../OptistreamNetworkingTests.swift | 19 +++++++++- .../Components/OptiTrack/OptiTrack.swift | 14 ++++--- .../Components/RealTime/RealTime.swift | 38 +++++++++++-------- .../Classes/Optimobile/AnalyticsHelper.swift | 9 +++-- .../Optimobile/Network/KSHttpClient.swift | 12 +++++- 9 files changed, 106 insertions(+), 32 deletions(-) diff --git a/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift b/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift index 3bacd352..844af620 100644 --- a/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift +++ b/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift @@ -68,7 +68,9 @@ extension NetworkClientImpl: NetworkClient { switch httpResponse.statusCode { case 200 ... 299: completion(.success(NetworkResponse(statusCode: httpResponse.statusCode, body: data))) - case 400 ... 499: + case 401: + completion(.failure(NetworkError.unauthorized(data))) + case 400, 402 ... 499: completion(.failure(NetworkError.requestInvalid(data))) case 500 ... 599: completion(.failure(NetworkError.requestFailed)) diff --git a/OptimoveCore/Sources/Classes/NetworkClient/NetworkError.swift b/OptimoveCore/Sources/Classes/NetworkClient/NetworkError.swift index a60e7cee..4423dc5d 100644 --- a/OptimoveCore/Sources/Classes/NetworkClient/NetworkError.swift +++ b/OptimoveCore/Sources/Classes/NetworkClient/NetworkError.swift @@ -6,6 +6,10 @@ public enum NetworkError: LocalizedError { case error(Error) case noData case invalidURL + case authFailed(Error) + case unauthorized(Data?) + /// Backend returned 401 but no AuthManager is configured — will never produce a JWT. + case authNotConfigured case requestInvalid(Data?) case requestFailed @@ -18,6 +22,16 @@ public enum NetworkError: LocalizedError { return "No data returns." case .invalidURL: return "Invalid URL." + case let .authFailed(error): + return "Auth token fetch failed: \(error.localizedDescription)" + case let .unauthorized(data): + var msg = "Unauthorized (401)." + if let data = data, let string = String(bytes: data, encoding: .utf8) { + msg = msg + "\n\(string)" + } + return msg + case .authNotConfigured: + return "Backend requires authentication but enableAuth() was not configured." case let .requestInvalid(data): var msg = "Invalid resquest." if let data = data, let string = String(bytes: data, encoding: .utf8) { diff --git a/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift b/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift index 0fa50426..3b45ab17 100644 --- a/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift +++ b/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift @@ -45,9 +45,14 @@ public final class OptistreamDispatcherImpl: OptistreamDispatcher { completion: @escaping () -> Void ) { guard let authManager = authManager else { - // No auth configured — send the entire batch as-is, no JWT + // No auth configured — send the entire batch as-is, no JWT. + // If the backend returns 401, this is permanent (no AuthManager to produce a JWT). networking.send(events: events, path: path, jwt: nil) { result in - onGroupResult(events, result) + if case .failure(.unauthorized) = result { + onGroupResult(events, .failure(.authNotConfigured)) + } else { + onGroupResult(events, result) + } completion() } return @@ -117,9 +122,9 @@ public final class OptistreamDispatcherImpl: OptistreamDispatcher { } case .failure(let error): Logger.error( - "Auth token fetch failed for userId '\(customerId)': \(error.localizedDescription). Dropping group." + "Auth token fetch failed for userId '\(customerId)': \(error.localizedDescription)" ) - onGroupResult(events, .failure(NetworkError.error(error))) + onGroupResult(events, .failure(NetworkError.authFailed(error))) completion() } } diff --git a/OptimoveCore/Sources/Classes/Optistream/OptistreamNetworking.swift b/OptimoveCore/Sources/Classes/Optistream/OptistreamNetworking.swift index 57a4d435..86042618 100644 --- a/OptimoveCore/Sources/Classes/Optistream/OptistreamNetworking.swift +++ b/OptimoveCore/Sources/Classes/Optistream/OptistreamNetworking.swift @@ -71,6 +71,21 @@ private extension OptistreamNetworkingImpl { """ ) completion(.success(())) + } catch let NetworkError.unauthorized(data) { + let body: String = { + guard let data = data else { return "no data" } + return String(decoding: data, as: UTF8.self) + }() + Logger.error( + """ + Optistream unauthorized (401): + request: + \(events.map(\.event).joined(separator: "\n")) + response body: + \(body) + """ + ) + completion(.failure(NetworkError.unauthorized(data))) } catch let NetworkError.requestInvalid(data) { let response: () -> String = { guard let data = data else { return "no data" } diff --git a/OptimoveCore/Tests/Sources/Optistream/OptistreamNetworkingTests.swift b/OptimoveCore/Tests/Sources/Optistream/OptistreamNetworkingTests.swift index 394a2cea..187bd0a5 100644 --- a/OptimoveCore/Tests/Sources/Optistream/OptistreamNetworkingTests.swift +++ b/OptimoveCore/Tests/Sources/Optistream/OptistreamNetworkingTests.swift @@ -105,14 +105,29 @@ final class OptistreamNetworkingTests: XCTestCase { } func test_send_requestInvalid_callsCompletionWithSuccess() { - let completionExpectation = expectation(description: "completion called with success on 4xx") + let completionExpectation = expectation(description: "completion called with success on non-401 4xx") networkClientSpy.mockResult = .failure(.requestInvalid(nil)) networking.send(events: [makeEvent()], path: nil, jwt: nil) { result in if case .success = result { completionExpectation.fulfill() } else { - XCTFail("Expected success on requestInvalid (4xx should still prune events)") + XCTFail("Expected success on requestInvalid (non-401 4xx should still prune events)") + } + } + + waitForExpectations(timeout: 1) + } + + func test_send_unauthorized_callsCompletionWithFailure() { + let completionExpectation = expectation(description: "completion called with failure on 401") + networkClientSpy.mockResult = .failure(.unauthorized(nil)) + + networking.send(events: [makeEvent()], path: nil, jwt: nil) { result in + if case .failure = result { + completionExpectation.fulfill() + } else { + XCTFail("Expected failure on 401 (events should be retried)") } } diff --git a/OptimoveSDK/Sources/Classes/Components/OptiTrack/OptiTrack.swift b/OptimoveSDK/Sources/Classes/Components/OptiTrack/OptiTrack.swift index 51159c1b..ec5d46d0 100644 --- a/OptimoveSDK/Sources/Classes/Components/OptiTrack/OptiTrack.swift +++ b/OptimoveSDK/Sources/Classes/Components/OptiTrack/OptiTrack.swift @@ -139,7 +139,7 @@ private extension OptiTrack { } guard !queue.isEmpty else { Logger.debug("No need to dispatch. Dispatch queue is empty.") - stopDispatching() + temporarilyStopDispatching() return } Logger.info("Start dispatching events") @@ -150,7 +150,7 @@ private extension OptiTrack { func dispatchBatch() { let events = queue.first(limit: Constants.eventBatchLimit) guard !events.isEmpty else { - stopDispatching() + temporarilyStopDispatching() Logger.info("Finished dispatching events") return } @@ -168,7 +168,11 @@ private extension OptiTrack { self.queue.remove(events: groupEvents) case let .failure(error): Logger.error(error.localizedDescription) - hasRetryableError = true + if case .authNotConfigured = error { + self.queue.remove(events: groupEvents) + } else { + hasRetryableError = true + } } } }, @@ -176,7 +180,7 @@ private extension OptiTrack { guard let self = self else { return } self.dispatchQueue.async { if hasRetryableError { - self.stopDispatching() + self.temporarilyStopDispatching() } else { self.dispatchBatch() } @@ -185,7 +189,7 @@ private extension OptiTrack { ) } - func stopDispatching() { + func temporarilyStopDispatching() { isDispatching = false stopBackgroundTask() startDispatchTimer() diff --git a/OptimoveSDK/Sources/Classes/Components/RealTime/RealTime.swift b/OptimoveSDK/Sources/Classes/Components/RealTime/RealTime.swift index b27ae14c..8ec54846 100644 --- a/OptimoveSDK/Sources/Classes/Components/RealTime/RealTime.swift +++ b/OptimoveSDK/Sources/Classes/Components/RealTime/RealTime.swift @@ -14,7 +14,8 @@ final class RealTime { } private let configuration: RealtimeConfig - private let realTimeQueue = DispatchQueue(label: "com.optimove.sdk.realtime", qos: .userInitiated) + private let realTimeQueue = DispatchQueue( + label: "com.optimove.sdk.realtime", qos: .userInitiated) private var storage: OptimoveStorage private let queue: OptistreamQueue private let dispatcher: OptistreamDispatcher @@ -40,7 +41,7 @@ extension RealTime: OptistreamComponent { let handleOperationOnQueue: () -> Void = { [weak self] in guard let self = self else { return } switch operation { - case let .report(events: events): + case .report(events: let events): let allowedEvents = events.filter(self.isAllowedToReport) guard !allowedEvents.isEmpty else { break } self.report(allowedEvents) @@ -52,18 +53,18 @@ extension RealTime: OptistreamComponent { } } -private extension RealTime { - func isAllowedToReport(_ event: OptistreamEvent) -> Bool { - return event.metadata.realtime && - !configuration.isEnableRealtimeThroughOptistream +extension RealTime { + fileprivate func isAllowedToReport(_ event: OptistreamEvent) -> Bool { + return event.metadata.realtime && !configuration.isEnableRealtimeThroughOptistream } - func report(_ events: [OptistreamEvent]) { + fileprivate func report(_ events: [OptistreamEvent]) { if queue.isEmpty { /// Simply send incoming events if the queue is empty sentReportEvent(events) } else { - let failProtectedEvents = queue.first(limit: max(Constants.eventBatchLimit - events.count, 1)) + let failProtectedEvents = queue.first( + limit: max(Constants.eventBatchLimit - events.count, 1)) /// Check if we have intersection between `failProtectedEvents` and incoming events. if events.filter(isFailProtectedEvent).isEmpty { /// If no intersection found – merge them and send. @@ -86,10 +87,10 @@ private extension RealTime { // MARK: - Private -private extension RealTime { +extension RealTime { // MARK: Send report - func sentReportEvent(_ events: [OptistreamEvent]) { + fileprivate func sentReportEvent(_ events: [OptistreamEvent]) { dispatcher.sendBatch( events: events, path: Constants.path, @@ -99,25 +100,30 @@ private extension RealTime { switch result { case .success: self.onSuccess(groupEvents) - case let .failure(error): + case .failure(let error): Logger.error(error.localizedDescription) - self.onError(groupEvents) + //authNotConfigured is a permanent error, so we remove all events from the queue + if case .authNotConfigured = error { + self.queue.remove(events: groupEvents) + } else { + self.onError(groupEvents) + } } } }, - completion: { } + completion: {} ) } - func onSuccess(_ events: [OptistreamEvent]) { + fileprivate func onSuccess(_ events: [OptistreamEvent]) { queue.remove(events: events.filter(isFailProtectedEvent)) } - func onError(_ events: [OptistreamEvent]) { + fileprivate func onError(_ events: [OptistreamEvent]) { queue.enqueue(events: events.filter(isFailProtectedEvent)) } - func isFailProtectedEvent(_ event: OptistreamEvent) -> Bool { + fileprivate func isFailProtectedEvent(_ event: OptistreamEvent) -> Bool { return Constants.failProtectedEvents.contains(event.event) } } diff --git a/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift b/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift index 06430448..caa6d6ad 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift @@ -2,6 +2,7 @@ import CoreData import Foundation +import OptimoveCore class KSEventModel: NSManagedObject { @NSManaged var uuid: String @@ -252,9 +253,11 @@ final class AnalyticsHelper { networkBarrier.wait() - if err != nil { - onSyncComplete?(err) - return + if let err { + guard case .authNotConfigured? = err as? NetworkError else { + onSyncComplete?(err) + return + } } if let err = self.pruneEventsBatch(context, eventIds) { diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift b/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift index 47676295..1f0e3601 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift @@ -95,7 +95,17 @@ final class KSHttpClientImpl: KSHttpClient { request.addValue("1", forHTTPHeaderField: "X-Optimove-Auth-Capable") guard let authManager = authManager, let userId = authUserId else { - sendRequest(request: request, onSuccess: onSuccess, onFailure: onFailure) + if authManager == nil, authUserId != nil { + sendRequest(request: request, onSuccess: onSuccess) { response, error, decodedBody in + if response?.statusCode == 401 { + onFailure(response, NetworkError.authNotConfigured, decodedBody) + } else { + onFailure(response, error, decodedBody) + } + } + } else { + sendRequest(request: request, onSuccess: onSuccess, onFailure: onFailure) + } return } From dd2cbe78878981562fddbeae37aab5db2377199e Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Thu, 23 Apr 2026 15:40:28 -0400 Subject: [PATCH 10/16] Added the platform to the federated auth headers logic --- .../Classes/NetworkClient/NetworkClient.swift | 1 + .../Tests/Sources/NetworkClientTests.swift | 4 +- .../Optimobile/Network/KSHttpClient.swift | 46 ++++++++++--------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift b/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift index 844af620..4deb6a56 100644 --- a/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift +++ b/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift @@ -55,6 +55,7 @@ extension NetworkClientImpl: NetworkClient { // Signal that this SDK version supports auth. urlRequest.addValue("1", forHTTPHeaderField: "X-Optimove-Auth-Capable") + urlRequest.addValue("ios", forHTTPHeaderField: "X-Optimove-Platform") let task = session.dataTask(with: urlRequest) { data, response, error in if let error = error { diff --git a/OptimoveCore/Tests/Sources/NetworkClientTests.swift b/OptimoveCore/Tests/Sources/NetworkClientTests.swift index 984af6b2..c4fd941c 100644 --- a/OptimoveCore/Tests/Sources/NetworkClientTests.swift +++ b/OptimoveCore/Tests/Sources/NetworkClientTests.swift @@ -77,7 +77,7 @@ class NetworkClientTests: XCTestCase { func test_perform_addsXOptimoveAuthCapableHeader() { // given - let headerExpectation = expectation(description: "X-Optimove-Auth-Capable header should be present") + let headerExpectation = expectation(description: "X-Optimove-Auth-Capable and X-Optimove-Platform headers should be present") var mock = Mock( url: StubVariables.url, @@ -88,6 +88,8 @@ class NetworkClientTests: XCTestCase { mock.onRequestHandler = OnRequestHandler(requestCallback: { urlRequest in let authCapable = urlRequest.value(forHTTPHeaderField: "X-Optimove-Auth-Capable") XCTAssertEqual(authCapable, "1", "Every request should include X-Optimove-Auth-Capable: 1") + let platform = urlRequest.value(forHTTPHeaderField: "X-Optimove-Platform") + XCTAssertEqual(platform, "ios", "Every request should include X-Optimove-Platform: ios") headerExpectation.fulfill() }) Mocker.register(mock) diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift b/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift index 1f0e3601..88337926 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/Network/KSHttpClient.swift @@ -93,32 +93,34 @@ final class KSHttpClientImpl: KSHttpClient { // Signal that this SDK version supports auth. request.addValue("1", forHTTPHeaderField: "X-Optimove-Auth-Capable") - - guard let authManager = authManager, let userId = authUserId else { - if authManager == nil, authUserId != nil { - sendRequest(request: request, onSuccess: onSuccess) { response, error, decodedBody in - if response?.statusCode == 401 { - onFailure(response, NetworkError.authNotConfigured, decodedBody) - } else { - onFailure(response, error, decodedBody) - } + request.addValue("ios", forHTTPHeaderField: "X-Optimove-Platform") + + switch (authManager, authUserId) { + case let (authManager?, userId?): + // Auth configured + user-identified request → resolve JWT, then send. + authManager.getToken(userId: userId) { [self] result in + switch result { + case .success(let jwt): + request.addValue(jwt, forHTTPHeaderField: "X-User-JWT") + self.sendRequest(request: request, onSuccess: onSuccess, onFailure: onFailure) + case .failure(let error): + // Fail-closed: drop the request + Logger.error("Auth token failed for Optimobile request: \(error.localizedDescription). Dropping request.") + onFailure(nil, error, nil) } - } else { - sendRequest(request: request, onSuccess: onSuccess, onFailure: onFailure) } - return - } - authManager.getToken(userId: userId) { [self] result in - switch result { - case .success(let jwt): - request.addValue(jwt, forHTTPHeaderField: "X-User-JWT") - self.sendRequest(request: request, onSuccess: onSuccess, onFailure: onFailure) - case .failure(let error): - // Fail-closed: drop the request - Logger.error("Auth token failed for Optimobile request: \(error.localizedDescription). Dropping request.") - onFailure(nil, error, nil) + case (nil, _?): + sendRequest(request: request, onSuccess: onSuccess) { response, error, decodedBody in + if response?.statusCode == 401 { + onFailure(response, NetworkError.authNotConfigured, decodedBody) + } else { + onFailure(response, error, decodedBody) + } } + + default: + sendRequest(request: request, onSuccess: onSuccess, onFailure: onFailure) } } catch { onFailure(nil, error, nil) From ec8f9ec3f76969e99bb91ad1181473fb220857c1 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Thu, 23 Apr 2026 15:50:59 -0400 Subject: [PATCH 11/16] Minor test build tweaks --- .../Storage/OptimoveFileManagerTests.swift | 4 +-- .../Classes/Migration/MigrationWork.swift | 2 +- .../Optimobile/KSHttpClientTests.swift | 26 +++++++++---------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/OptimoveCore/Tests/Sources/Storage/OptimoveFileManagerTests.swift b/OptimoveCore/Tests/Sources/Storage/OptimoveFileManagerTests.swift index 3cff64a3..7ad49117 100644 --- a/OptimoveCore/Tests/Sources/Storage/OptimoveFileManagerTests.swift +++ b/OptimoveCore/Tests/Sources/Storage/OptimoveFileManagerTests.swift @@ -26,8 +26,8 @@ class OptimoveFileManagerTests: XCTestCase { override func setUp() { fileStorage = try! FileStorageImpl( - persistentStorageURL: try! FileManager.optimoveURL(), - temporaryStorageURL: try! FileManager.temporaryURL() + persistentStorageURL: FileManager.optimoveURL(), + temporaryStorageURL: FileManager.temporaryURL() ) } diff --git a/OptimoveSDK/Sources/Classes/Migration/MigrationWork.swift b/OptimoveSDK/Sources/Classes/Migration/MigrationWork.swift index cd8e526a..5792d7a6 100644 --- a/OptimoveSDK/Sources/Classes/Migration/MigrationWork.swift +++ b/OptimoveSDK/Sources/Classes/Migration/MigrationWork.swift @@ -157,7 +157,7 @@ extension MigrationWork_3_3_0 { func moveFilesFromAppGroup() throws { let bundleID = try Bundle.getApplicationNameSpace() let oldURL = try getDeprecatedFileManagerGroupContainerURL(tenantBundleIdentifier: bundleID).appendingPathComponent("OptimoveSDK") - let newURL = try FileManager.optimoveURL() + let newURL = FileManager.optimoveURL() let fileManager = FileManager.default var isDirectory: ObjCBool = false let exists = FileManager.default.fileExists(atPath: oldURL.absoluteString, isDirectory: &isDirectory) diff --git a/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift b/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift index 17e840c6..c865fcbf 100644 --- a/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift +++ b/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift @@ -15,22 +15,20 @@ private class StubAuthorization: HttpAuthorizationProtocol { // MARK: - Stub URL Builder with runtime URLs private class StubUrlBuilder: UrlBuilder { - init() { - super.init( - storage: KeyValPersistenceHelper.self, - runtimeUrlsMap: [ - .events: "https://test-events.example.com", - .crm: "https://test-crm.example.com", - .ddl: "https://test-ddl.example.com", - .iar: "https://test-iar.example.com", - .media: "https://test-media.example.com", - .push: "https://test-push.example.com", - ] - ) + convenience init() { + self.init(storage: KeyValPersistenceHelper.self) + self.runtimeUrlsMap = [ + .events: "https://test-events.example.com", + .crm: "https://test-crm.example.com", + .ddl: "https://test-ddl.example.com", + .iar: "https://test-iar.example.com", + .media: "https://test-media.example.com", + .push: "https://test-push.example.com", + ] } - required init(storage: KeyValPersistenceHelper.Type, runtimeUrlsMap: ServiceUrlMap? = nil) { - super.init(storage: storage, runtimeUrlsMap: runtimeUrlsMap) + required init(storage: KeyValPersistenceHelper.Type) { + super.init(storage: storage) } } From 47cad1f4115443bcec323bfc51971c42dd0e1d4d Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Thu, 23 Apr 2026 16:19:15 -0400 Subject: [PATCH 12/16] Embedded leftovers tests --- .../Network/EmbeddedMessagingTests.swift | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift b/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift index b0d5c2d9..1c935095 100644 --- a/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift +++ b/OptimoveSDK/Tests/Sources/Network/EmbeddedMessagingTests.swift @@ -456,9 +456,9 @@ final class EmbeddedMessagingAuthTests: XCTestCase { readAt: nil, url: nil, engagementId: "eng123", - payload: ["key": AnyCodable("string")], + payload: "{\"key\":\"string\"}", campaignKind: 1, - executionDateTime: "2025-01-01T12:00:00Z", + executionDateTime: Date(timeIntervalSince1970: 1735732800), messageLayoutType: nil, expiryDate: nil, containerId: "test-container", @@ -469,11 +469,39 @@ final class EmbeddedMessagingAuthTests: XCTestCase { ) } + // EmbeddedMessagingAPIResponse is intentionally decode-only (it remaps + // raw wire shape → public model). For tests we craft the wire JSON directly. + private func makeMockGetMessagesResponseData() -> Data { + let json = """ + {"containers":{"test-container":[{ + "id":"test-id", + "containerId":"test-container", + "customerId":"adam_b@optimove.com", + "isVisitor":false, + "templateId":1, + "title":"Test Title", + "content":"Test content", + "media":null, + "readAt":null, + "url":null, + "engagementId":"eng123", + "payload":{"key":"string"}, + "campaignKind":1, + "executionDateTime":"2025-01-01T12:00:00Z", + "messageLayoutType":null, + "expiryDate":null, + "createdAt":"2025-07-16T12:00:00Z", + "updatedAt":"2025-07-16T12:00:00Z", + "deletedAt":null + }]}} + """ + return json.data(using: .utf8)! + } + func test_getMessagesAsync_noAuthManager_sendsRequestWithoutJWT() throws { let service = EmbeddedMessagesService(storage: mockStorage, networkClient: mockNetworkClient, authManager: nil) - let apiResponse = EmbeddedMessagingAPIResponse(containers: ["test-container": [makeTestMessage()]]) - let jsonData = try JSONEncoder().encode(apiResponse) + let jsonData = makeMockGetMessagesResponseData() let mockNetworkResponse = NetworkResponse.testMock(statusCode: 200, body: jsonData) mockNetworkClient.mockResponse = .success(mockNetworkResponse) @@ -501,8 +529,7 @@ final class EmbeddedMessagingAuthTests: XCTestCase { } let service = EmbeddedMessagesService(storage: mockStorage, networkClient: mockNetworkClient, authManager: authManager) - let apiResponse = EmbeddedMessagingAPIResponse(containers: ["test-container": [makeTestMessage()]]) - let jsonData = try JSONEncoder().encode(apiResponse) + let jsonData = makeMockGetMessagesResponseData() let mockNetworkResponse = NetworkResponse.testMock(statusCode: 200, body: jsonData) mockNetworkClient.mockResponse = .success(mockNetworkResponse) From 0b3e3b4e55a56b21417f69d544739cbf5fe54909 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Mon, 27 Apr 2026 17:25:27 -0400 Subject: [PATCH 13/16] Added timeout to AuthManager token fetch to prevent hanging providers --- .../Sources/Classes/Auth/AuthManager.swift | 50 ++++++++++++++--- .../Tests/Sources/Auth/AuthManagerTests.swift | 54 +++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/OptimoveCore/Sources/Classes/Auth/AuthManager.swift b/OptimoveCore/Sources/Classes/Auth/AuthManager.swift index 3b63830a..2078242b 100644 --- a/OptimoveCore/Sources/Classes/Auth/AuthManager.swift +++ b/OptimoveCore/Sources/Classes/Auth/AuthManager.swift @@ -14,12 +14,15 @@ public typealias AuthTokenProvider = ( /// Errors related to auth token fetching. public enum AuthError: Error, LocalizedError { case tokenFetchFailed + case tokenFetchTimedOut case noUserId public var errorDescription: String? { switch self { case .tokenFetchFailed: return "Failed to fetch auth token from provider." + case .tokenFetchTimedOut: + return "Timed out fetching auth token from provider." case .noUserId: return "No userId available for auth token request." } @@ -28,26 +31,61 @@ public enum AuthError: Error, LocalizedError { /// Manages JWT token retrieval from the client-provided closure. /// - Threading: `getToken` can be called from any queue. The `completion` callback is invoked -/// on the queue the client's `AuthTokenProvider` closure dispatches to — callers should -/// not assume any specific queue and should dispatch to their target queue if needed. +/// on the queue the client's `AuthTokenProvider` closure dispatches to, or on a background queue +/// if the provider times out — callers should not assume any specific queue and should dispatch +/// to their target queue if needed. public final class AuthManager { private let provider: AuthTokenProvider + private let tokenFetchTimeout: TimeInterval - public init(provider: @escaping AuthTokenProvider) { + public init( + tokenFetchTimeout: TimeInterval = 10, + provider: @escaping AuthTokenProvider + ) { self.provider = provider + self.tokenFetchTimeout = tokenFetchTimeout } /// Request a JWT for the given userId from the client-provided closure. /// - Parameters: /// - userId: The user identifier to authenticate. /// - completion: Called with `.success(token)` or `.failure(error)`. - /// Invoked on the queue chosen by the client's `AuthTokenProvider` — no specific queue is guaranteed. + /// Invoked on the queue chosen by the client's `AuthTokenProvider`, or on a background queue + /// if the provider times out — no specific queue is guaranteed. public func getToken(userId: String, completion: @escaping (Result) -> Void) { + let lock = NSLock() + var didComplete = false + var timeoutWorkItem: DispatchWorkItem? + + func completeOnce(_ result: Result) { + lock.lock() + guard !didComplete else { + lock.unlock() + return + } + didComplete = true + let workItem = timeoutWorkItem + timeoutWorkItem = nil + lock.unlock() + workItem?.cancel() + completion(result) + } + + if tokenFetchTimeout > 0 { + let workItem = DispatchWorkItem { + completeOnce(.failure(AuthError.tokenFetchTimedOut)) + } + lock.lock() + timeoutWorkItem = workItem + lock.unlock() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + tokenFetchTimeout, execute: workItem) + } + provider(userId) { token, error in if let token = token { - completion(.success(token)) + completeOnce(.success(token)) } else { - completion(.failure(error ?? AuthError.tokenFetchFailed)) + completeOnce(.failure(error ?? AuthError.tokenFetchFailed)) } } } diff --git a/OptimoveCore/Tests/Sources/Auth/AuthManagerTests.swift b/OptimoveCore/Tests/Sources/Auth/AuthManagerTests.swift index 394fe7c5..c0e216e9 100644 --- a/OptimoveCore/Tests/Sources/Auth/AuthManagerTests.swift +++ b/OptimoveCore/Tests/Sources/Auth/AuthManagerTests.swift @@ -84,4 +84,58 @@ final class AuthManagerTests: XCTestCase { } waitForExpectations(timeout: 1) } + + // MARK: - 1.5 getToken returns timeout when provider does not complete + + func test_getToken_returnsTimeoutWhenProviderDoesNotComplete() { + let completionExpectation = expectation(description: "Completion should receive .failure(tokenFetchTimedOut)") + let authManager = AuthManager(tokenFetchTimeout: 0.05) { _, _ in + // Simulates a tenant token provider that never calls completion. + } + + authManager.getToken(userId: "user-123") { result in + switch result { + case .success: + XCTFail("Expected failure but got success") + case .failure(let error): + guard let authError = error as? AuthError else { + XCTFail("Expected AuthError but got \(type(of: error))") + completionExpectation.fulfill() + return + } + XCTAssertEqual(authError, AuthError.tokenFetchTimedOut) + } + completionExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 1.6 getToken ignores provider completion after timeout + + func test_getToken_ignoresProviderCompletionAfterTimeout() { + let timeoutExpectation = expectation(description: "Completion should receive timeout once") + let lateCompletionExpectation = expectation(description: "Late provider completion should be ignored") + lateCompletionExpectation.isInverted = true + + let authManager = AuthManager(tokenFetchTimeout: 0.05) { _, completion in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + completion("late-token", nil) + } + } + + authManager.getToken(userId: "user-123") { result in + switch result { + case .success: + lateCompletionExpectation.fulfill() + case .failure(let error): + guard let authError = error as? AuthError else { + XCTFail("Expected AuthError but got \(type(of: error))") + return + } + XCTAssertEqual(authError, AuthError.tokenFetchTimedOut) + timeoutExpectation.fulfill() + } + } + wait(for: [timeoutExpectation, lateCompletionExpectation], timeout: 0.3) + } } From daf46ad82258ed1b11344b5c522b3deb1c9af6a2 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Wed, 6 May 2026 10:06:10 -0400 Subject: [PATCH 14/16] Added JWT to overlay --- .../OverlayMessaging/OverlayMessagingRequestService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OptimoveSDK/Sources/Classes/Optimobile/OverlayMessaging/OverlayMessagingRequestService.swift b/OptimoveSDK/Sources/Classes/Optimobile/OverlayMessaging/OverlayMessagingRequestService.swift index 823c1993..ae8a8077 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/OverlayMessaging/OverlayMessagingRequestService.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/OverlayMessaging/OverlayMessagingRequestService.swift @@ -19,7 +19,7 @@ class OverlayMessagingRequestService { let messageType = type == .session ? "session" : "immediate" let path = "/api/v1/users/\(encodedIdentifier)/messages/mobile?messageType=\(messageType)" - httpClient.sendRequest(.GET, toPath: path, data: nil, onSuccess: { response, decodedBody in + httpClient.sendRequest(.GET, toPath: path, data: nil, authUserId: OptimobileHelper.associatedUserId, onSuccess: { response, decodedBody in if response?.statusCode == 204 { onComplete(nil) return From a70effc8e5eb45f8e88674109d2a7568698da58c Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Wed, 6 May 2026 15:50:51 -0400 Subject: [PATCH 15/16] Removed redundant tests --- .../Optimobile/KSHttpClientTests.swift | 67 ------------------- 1 file changed, 67 deletions(-) diff --git a/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift b/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift index c865fcbf..c699e7ba 100644 --- a/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift +++ b/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift @@ -101,71 +101,4 @@ final class KSHttpClientTests: XCTestCase { waitForExpectations(timeout: 2) client.invalidateSessionCancellingTasks(true) } - - func test_sendRequest_noAuthManager_doesNotCallGetToken() { - let client = KSHttpClientImpl( - serviceType: .events, - urlBuilder: StubUrlBuilder(), - requestFormat: .json, - responseFormat: .json, - authorization: StubAuthorization(), - authManager: nil - ) - - // With no authManager, the request should proceed directly. - // We can't easily verify the absence of X-User-JWT header here, - // but we verify the code path doesn't crash and proceeds to network. - let responseExpectation = expectation(description: "Request proceeds without auth") - - client.sendRequest( - .GET, - toPath: "/v1/test", - data: nil, - authUserId: nil, - onSuccess: { _, _ in - responseExpectation.fulfill() - }, - onFailure: { _, _, _ in - // Network failure is expected (no real server) — still proves request was sent - responseExpectation.fulfill() - } - ) - - waitForExpectations(timeout: 5) - client.invalidateSessionCancellingTasks(true) - } - - func test_sendRequest_nilAuthUserId_skipsJWT() { - let authManager = AuthManager { _, completion in - XCTFail("getToken should not be called when authUserId is nil") - completion(nil, NSError(domain: "test", code: 0)) - } - - let client = KSHttpClientImpl( - serviceType: .events, - urlBuilder: StubUrlBuilder(), - requestFormat: .json, - responseFormat: .json, - authorization: StubAuthorization(), - authManager: authManager - ) - - let responseExpectation = expectation(description: "Request proceeds without JWT") - - client.sendRequest( - .GET, - toPath: "/v1/test", - data: nil, - authUserId: nil, - onSuccess: { _, _ in - responseExpectation.fulfill() - }, - onFailure: { _, _, _ in - responseExpectation.fulfill() - } - ) - - waitForExpectations(timeout: 5) - client.invalidateSessionCancellingTasks(true) - } } From 953daf0a9fb58ee689013abc336a2eb26257bffc Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Wed, 6 May 2026 20:08:40 -0400 Subject: [PATCH 16/16] Tests tweaks --- .../Optimobile/AnalyticsHelperTests.swift | 99 ++++++++++--------- 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift b/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift index adedf7d5..7dac07f7 100644 --- a/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift +++ b/OptimoveSDK/Tests/Sources/Optimobile/AnalyticsHelperTests.swift @@ -38,78 +38,85 @@ class AnalyticsHelperTests: XCTestCase { super.tearDown() } + // Closures below capture `mockHttpClient` / `analyticsHelper` as local strong refs + // so a late callback firing after tearDown can't crash on `self.mockHttpClient!`. + func test_number_of_sent_events_same_as_tracked() { let numberOfEvents = 4 let numberOfEventsExpectation = expectation(description: "Number of events wasnt \(numberOfEvents)") - + let mock = mockHttpClient! + for i in 1...numberOfEvents - 1 { analyticsHelper.trackEvent(eventType: "immediate_event\(i)", properties: nil, immediateFlush: true) } - + self.analyticsHelper.trackEvent(eventType: "immediate_event_last", atTime: Date(), properties: nil, immediateFlush: true) {_ in - if let data = self.mockHttpClient.capturedData as? [[String: Any?]], data.count == numberOfEvents { + if let data = mock.capturedData as? [[String: Any?]], data.count == numberOfEvents { numberOfEventsExpectation.fulfill() } } - + waitForExpectations(timeout: longTimeoutInSeconds, handler: nil) } - + func test_number_of_sent_events_with_delays_same_as_tracked() { let numberOfEvents = 4 let numberOfEventsExpectation = expectation(description: "Number of events wasnt \(numberOfEvents)") - - analyticsHelper.trackEvent(eventType: "immediate_event_first", properties: nil, immediateFlush: true) - + let mock = mockHttpClient! + let helper = analyticsHelper! + + helper.trackEvent(eventType: "immediate_event_first", properties: nil, immediateFlush: true) + DispatchQueue.global().asyncAfter(deadline: .now() + 2) { for i in 1...numberOfEvents - 1 { - self.analyticsHelper.trackEvent(eventType: "immediate_event\(i)", properties: nil, immediateFlush: true) + helper.trackEvent(eventType: "immediate_event\(i)", properties: nil, immediateFlush: true) } - - self.analyticsHelper.trackEvent(eventType: "immediate_event_last", atTime: Date(), properties: nil, immediateFlush: true) {_ in + + helper.trackEvent(eventType: "immediate_event_last", atTime: Date(), properties: nil, immediateFlush: true) {_ in // +1 for immediate_event_first tracked before the delay - let count = self.mockHttpClient.totalEventCount - - if self.mockHttpClient.totalEventCount == numberOfEvents + 1 { + if mock.totalEventCount == numberOfEvents + 1 { numberOfEventsExpectation.fulfill() } } } - + waitForExpectations(timeout: longTimeoutInSeconds, handler: nil) } - + func test_number_of_sent_events_from_background_threads_same_as_tracked() { let numberOfEvents = 4 let numberOfEventsExpectation = expectation(description: "Number of events wasnt \(numberOfEvents)") - - + + let mock = mockHttpClient! + let helper = analyticsHelper! + for i in 1...numberOfEvents - 1 { DispatchQueue.global().async { - self.analyticsHelper.trackEvent(eventType: "immediate_event\(i)", properties: nil, immediateFlush: true) + helper.trackEvent(eventType: "immediate_event\(i)", properties: nil, immediateFlush: true) } } - - analyticsHelper.trackEvent(eventType: "immediate_event_last", atTime: Date(), properties: nil, immediateFlush: true) {_ in - if let data = self.mockHttpClient.capturedData as? [[String: Any?]], data.count == numberOfEvents { + + helper.trackEvent(eventType: "immediate_event_last", atTime: Date(), properties: nil, immediateFlush: true) {_ in + if let data = mock.capturedData as? [[String: Any?]], data.count == numberOfEvents { numberOfEventsExpectation.fulfill() } } - + waitForExpectations(timeout: longTimeoutInSeconds, handler: nil) } - + func test_immediate_event_should_grab_nonimmediate() { let nonImmediateSentExpectation = expectation(description: "Non immediate wasn't sent with immediate") - - + + let mock = mockHttpClient! + analyticsHelper.trackEvent(eventType: "regular_event", properties: nil, immediateFlush: false) analyticsHelper.trackEvent(eventType: "immediate_event", atTime: Date(), properties: nil, immediateFlush: true) {_ in - if let data = self.mockHttpClient.capturedData as? [[String: Any?]], data.count == 2 { + if let data = mock.capturedData as? [[String: Any?]], data.count == 2 { nonImmediateSentExpectation.fulfill() } } - + waitForExpectations(timeout: longTimeoutInSeconds, handler: nil) } @@ -137,13 +144,11 @@ class AnalyticsHelperTests: XCTestCase { class MockKSHttpClient: KSHttpClient { private var allBatches: [[String: Any?]] = [] - // All events accumulated across all requests (tests inspect this as the captured payload) + // Events accumulated across all sendRequest calls. var capturedData: Any? { allBatches.isEmpty ? nil : allBatches } - - // Total events accumulated across all requests var totalEventCount: Int { allBatches.count } - // Auth tracking: last authUserId and the full history across requests + // Last authUserId seen, and the full history. var capturedAuthUserId: String? var capturedAuthUserIds: [String?] = [] @@ -172,24 +177,36 @@ class AnalyticsHelperAuthTests: XCTestCase { override func setUp() { super.setUp() + // Isolate from prior runs: stale events / leftover USER_ID can corrupt expectations. + clearAnalyticsStore() + KeyValPersistenceHelper.removeObject(forKey: OptimobileUserDefaultsKey.USER_ID.rawValue) mockHttpClient = MockKSHttpClient() analyticsHelper = AnalyticsHelper(httpClient: mockHttpClient) } override func tearDown() { + KeyValPersistenceHelper.removeObject(forKey: OptimobileUserDefaultsKey.USER_ID.rawValue) analyticsHelper = nil mockHttpClient = nil super.tearDown() } - // By default, without calling associateUserWithInstall, currentUserIdentifier == installId. - // The syncEventsBatch code detects this as a visitor batch and passes authUserId: nil. + private func clearAnalyticsStore() { + guard let docsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last else { return } + let dbUrl = docsUrl.appendingPathComponent("KAnalyticsDb.sqlite") + for suffix in ["", "-shm", "-wal"] { + let url = dbUrl.deletingLastPathComponent().appendingPathComponent(dbUrl.lastPathComponent + suffix) + try? FileManager.default.removeItem(at: url) + } + } + // No user associated → currentUserIdentifier == installId → syncEventsBatch passes authUserId: nil. func test_syncEventsBatch_defaultVisitorEvents_passesNilAuthUserId() { let authUserIdExpectation = expectation(description: "authUserId should be nil for visitor events") + let mock = mockHttpClient! analyticsHelper.trackEvent(eventType: "visitor_event", atTime: Date(), properties: nil, immediateFlush: true) { _ in - if self.mockHttpClient.capturedAuthUserId == nil { + if mock.capturedAuthUserId == nil { authUserIdExpectation.fulfill() } } @@ -197,27 +214,21 @@ class AnalyticsHelperAuthTests: XCTestCase { waitForExpectations(timeout: longTimeoutInSeconds, handler: nil) } - // After associateUserWithInstall, currentUserIdentifier returns the user's ID. - // Events stamped with that ID should be sent with authUserId set. - + // User associated → events stamped with that id → syncEventsBatch passes authUserId: . func test_syncEventsBatch_associatedUser_passesAuthUserId() { let testUserId = "user-123" - - // Associate a user. This changes OptimobileHelper.currentUserIdentifier. KeyValPersistenceHelper.set(testUserId, forKey: OptimobileUserDefaultsKey.USER_ID.rawValue) let authUserIdExpectation = expectation(description: "authUserId should be the user's identifier") + let mock = mockHttpClient! analyticsHelper.trackEvent(eventType: "user_event", atTime: Date(), properties: nil, immediateFlush: true) { _ in - if self.mockHttpClient.capturedAuthUserId == testUserId { + if mock.capturedAuthUserId == testUserId { authUserIdExpectation.fulfill() } } waitForExpectations(timeout: longTimeoutInSeconds, handler: nil) - - // Cleanup: restore to visitor - KeyValPersistenceHelper.removeObject(forKey: OptimobileUserDefaultsKey.USER_ID.rawValue) } }