diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6840f1..97235ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 6.7.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.6.0 - Implementation for Overlay Messaging channel. Check optimove developer docs for more. 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/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/Sources/Classes/Auth/AuthManager.swift b/OptimoveCore/Sources/Classes/Auth/AuthManager.swift new file mode 100644 index 00000000..2078242b --- /dev/null +++ b/OptimoveCore/Sources/Classes/Auth/AuthManager.swift @@ -0,0 +1,96 @@ +// 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 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." + } + } +} + +/// 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, 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( + 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`, 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 { + completeOnce(.success(token)) + } else { + completeOnce(.failure(error ?? AuthError.tokenFetchFailed)) + } + } + } +} + +#if swift(>=5.5) +extension AuthManager: @unchecked Sendable {} +#endif diff --git a/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift b/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift index db7ff256..262886dc 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.6.0" +public let SDKVersion = "6.7.0" diff --git a/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift b/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift index 236bfa44..4deb6a56 100644 --- a/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift +++ b/OptimoveCore/Sources/Classes/NetworkClient/NetworkClient.swift @@ -53,6 +53,10 @@ 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") + urlRequest.addValue("ios", forHTTPHeaderField: "X-Optimove-Platform") + let task = session.dataTask(with: urlRequest) { data, response, error in if let error = error { completion(.failure(NetworkError.error(error))) @@ -65,7 +69,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/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/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift b/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift new file mode 100644 index 00000000..3b45ab17 --- /dev/null +++ b/OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift @@ -0,0 +1,132 @@ +// 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. + // If the backend returns 401, this is permanent (no AuthManager to produce a JWT). + networking.send(events: events, path: path, jwt: nil) { result in + if case .failure(.unauthorized) = result { + onGroupResult(events, .failure(.authNotConfigured)) + } else { + onGroupResult(events, result) + } + completion() + } + return + } + + // 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 groups = Array(grouped) + + if groups.count <= 1, let first = groups.first { + // All events belong to the same customer (or all anonymous) — no splitting needed + sendGroup( + events: events, + path: path, + customerId: first.key, + 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)" + ) + onGroupResult(events, .failure(NetworkError.authFailed(error))) + 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), diff --git a/OptimoveCore/Sources/Classes/Optistream/OptistreamNetworking.swift b/OptimoveCore/Sources/Classes/Optistream/OptistreamNetworking.swift index 84173a50..86042618 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], @@ -83,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/Auth/AuthManagerTests.swift b/OptimoveCore/Tests/Sources/Auth/AuthManagerTests.swift new file mode 100644 index 00000000..c0e216e9 --- /dev/null +++ b/OptimoveCore/Tests/Sources/Auth/AuthManagerTests.swift @@ -0,0 +1,141 @@ +// 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) + } + + // 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) + } +} diff --git a/OptimoveCore/Tests/Sources/NetworkClientTests.swift b/OptimoveCore/Tests/Sources/NetworkClientTests.swift index 15ebdd62..c4fd941c 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,46 @@ class NetworkClientTests: XCTestCase { // then wait(for: [success], timeout: defaultTimeout) } + + func test_perform_addsXOptimoveAuthCapableHeader() { + // given + let headerExpectation = expectation(description: "X-Optimove-Auth-Capable and X-Optimove-Platform headers 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") + let platform = urlRequest.value(forHTTPHeaderField: "X-Optimove-Platform") + XCTAssertEqual(platform, "ios", "Every request should include X-Optimove-Platform: ios") + 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..187bd0a5 --- /dev/null +++ b/OptimoveCore/Tests/Sources/Optistream/OptistreamNetworkingTests.swift @@ -0,0 +1,151 @@ +// 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 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 (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)") + } + } + + 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/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/Components/OptiTrack/OptiTrack.swift b/OptimoveSDK/Sources/Classes/Components/OptiTrack/OptiTrack.swift index d470555a..ec5d46d0 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() } @@ -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,32 +150,46 @@ private extension OptiTrack { func dispatchBatch() { let events = queue.first(limit: Constants.eventBatchLimit) guard !events.isEmpty else { - stopDispatching() + temporarilyStopDispatching() 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) + if case .authNotConfigured = error { + self.queue.remove(events: groupEvents) + } else { + hasRetryableError = true + } + } + } + }, + completion: { [weak self] in + guard let self = self else { return } + self.dispatchQueue.async { + if hasRetryableError { + self.temporarilyStopDispatching() + } else { + self.dispatchBatch() } - self.stopDispatching() } } - } + ) } - 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 861574c8..8ec54846 100644 --- a/OptimoveSDK/Sources/Classes/Components/RealTime/RealTime.swift +++ b/OptimoveSDK/Sources/Classes/Components/RealTime/RealTime.swift @@ -14,22 +14,23 @@ 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 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 } } @@ -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,34 +87,43 @@ private extension RealTime { // MARK: - Private -private extension RealTime { +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 + fileprivate func sentReportEvent(_ events: [OptistreamEvent]) { + 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 .failure(let error): + Logger.error(error.localizedDescription) + //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: {} + ) } - 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/EmbeddedMessaging/EmbeddedMessaging.swift b/OptimoveSDK/Sources/Classes/EmbeddedMessaging/EmbeddedMessaging.swift index 045faef9..de4c8792 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: Swift.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/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/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/Sources/Classes/Optimobile/AnalyticsHelper.swift b/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift index e907d206..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 @@ -170,7 +171,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 +180,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 +212,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 +234,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 @@ -245,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) { @@ -275,20 +285,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 4de21786..8ca5df5e 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppManager.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppManager.swift @@ -281,7 +281,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..88337926 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,44 @@ 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") + 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) + } + } + + 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) } 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 f233942e..c9d0e1f5 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 82808af5..e4c07467 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) @@ -198,15 +199,17 @@ 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/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 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 f621cd2f..fc4ae70e 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) @@ -133,6 +135,7 @@ open class OptimoveConfigBuilder: NSObject { private var _pushReceivedInForegroundHandlerBlock: Any? private var _deepLinkCname: URL? private var _deepLinkHandler: DeepLinkHandler? + private var _authTokenProvider: AuthTokenProvider? private var _overlayMessagingSessionLengthHours: Int? private var _runtimeInfo: [String: AnyObject]? private var _sdkInfo: [String: AnyObject]? @@ -185,6 +188,7 @@ open class OptimoveConfigBuilder: NSObject { _isRelease = optimobileConfig.isRelease _overlayMessagingSessionLengthHours = optimobileConfig.isOverlayMessagingEnabled ? optimobileConfig.overlayMessagingSessionLengthHours : nil } + _authTokenProvider = config.authTokenProvider features = config.features } @@ -312,6 +316,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 @@ -427,7 +445,8 @@ open class OptimoveConfigBuilder: NSObject { tenantInfo: tenantInfo, optimobileConfig: optimobileConfig, preferenceCenterConfig: preferenceCenterConfig, - embeddedMessagingConfig: embeddedMessagingConfig + embeddedMessagingConfig: embeddedMessagingConfig, + authTokenProvider: _authTokenProvider ) } diff --git a/OptimoveSDK/Sources/Classes/PreferenceCenter/OptimovePreferenceCenter.swift b/OptimoveSDK/Sources/Classes/PreferenceCenter/OptimovePreferenceCenter.swift index ab90416b..6b8d913a 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: Swift.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)") } 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 ) } 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 d7139cb9..1c935095 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 ) } } @@ -417,6 +418,241 @@ 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\":\"string\"}", + campaignKind: 1, + executionDateTime: Date(timeIntervalSince1970: 1735732800), + messageLayoutType: nil, + expiryDate: nil, + containerId: "test-container", + id: "test-id", + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: nil + ) + } + + // 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 jsonData = makeMockGetMessagesResponseData() + 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 jsonData = makeMockGetMessagesResponseData() + 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 5d373445..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,16 +144,20 @@ class AnalyticsHelperTests: XCTestCase { class MockKSHttpClient: KSHttpClient { private var allBatches: [[String: Any?]] = [] - // Last batch sent (used by tests that check a single-batch 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 } - func sendRequest(_ method: OptimoveSDK.KSHttpMethod, toPath path: String, data: Any?, onSuccess: @escaping OptimoveSDK.KSHttpSuccessBlock, onFailure: @escaping OptimoveSDK.KSHttpFailureBlock) { + // Last authUserId seen, and the full history. + 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) { if let batch = data as? [[String: Any?]] { allBatches.append(contentsOf: batch) } + capturedAuthUserId = authUserId + capturedAuthUserIds.append(authUserId) onSuccess(nil, nil) } @@ -156,11 +167,76 @@ 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() + // 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() + } + + 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 mock.capturedAuthUserId == nil { + authUserIdExpectation.fulfill() + } + } + + waitForExpectations(timeout: longTimeoutInSeconds, handler: nil) + } + + // User associated → events stamped with that id → syncEventsBatch passes authUserId: . + func test_syncEventsBatch_associatedUser_passesAuthUserId() { + let testUserId = "user-123" + 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 mock.capturedAuthUserId == testUserId { + authUserIdExpectation.fulfill() + } + } + + waitForExpectations(timeout: longTimeoutInSeconds, handler: nil) + } +} + 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 diff --git a/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift b/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift new file mode 100644 index 00000000..c699e7ba --- /dev/null +++ b/OptimoveSDK/Tests/Sources/Optimobile/KSHttpClientTests.swift @@ -0,0 +1,104 @@ +// 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 { + 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) { + super.init(storage: storage) + } +} + +// 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) + } +} 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" } } ] 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) } }