diff --git a/CustomerIOMessagingInApp.podspec b/CustomerIOMessagingInApp.podspec index 41d1b6a54..25bfc59ad 100644 --- a/CustomerIOMessagingInApp.podspec +++ b/CustomerIOMessagingInApp.podspec @@ -26,4 +26,5 @@ Pod::Spec.new do |spec| spec.module_name = "CioMessagingInApp" # the `import X` name when using SDK in Swift files spec.dependency "CustomerIOCommon", "= #{spec.version.to_s}" + spec.dependency "LDSwiftEventSource", "~> 3.3" end diff --git a/Package.swift b/Package.swift index 30b3bacda..f8ef794d5 100644 --- a/Package.swift +++ b/Package.swift @@ -45,7 +45,10 @@ let package = Package( // Make sure the version number is same for DataPipelines cocoapods. - .package(name: "CioAnalytics", url: "https://github.com/customerio/cdp-analytics-swift.git", .exact("1.7.3+cio.1")) + .package(name: "CioAnalytics", url: "https://github.com/customerio/cdp-analytics-swift.git", .exact("1.7.3+cio.1")), + + // SSE (Server-Sent Events) client for real-time in-app messaging + .package(url: "https://github.com/LaunchDarkly/swift-eventsource.git", .upToNextMajor(from: "3.3.0")) ], targets: [ // Common - Code used by multiple modules in the SDK project. @@ -113,7 +116,10 @@ let package = Package( // Messaging in-app .target(name: "CioMessagingInApp", - dependencies: ["CioInternalCommon"], + dependencies: [ + "CioInternalCommon", + .product(name: "LDSwiftEventSource", package: "swift-eventsource") + ], path: "Sources/MessagingInApp", resources: [ .process("Resources/PrivacyInfo.xcprivacy"), diff --git a/Sources/MessagingInApp/Gist/Gist.swift b/Sources/MessagingInApp/Gist/Gist.swift index 568398213..856e1ce7c 100644 --- a/Sources/MessagingInApp/Gist/Gist.swift +++ b/Sources/MessagingInApp/Gist/Gist.swift @@ -25,8 +25,11 @@ class Gist: GistProvider { private let inAppMessageManager: InAppMessageManager private let queueManager: QueueManager private let threadUtil: ThreadUtil + private let sseLifecycleManager: SseLifecycleManager - private var inAppMessageStoreSubscriber: InAppMessageStoreSubscriber? + private var pollIntervalSubscriber: InAppMessageStoreSubscriber? + private var sseEnabledSubscriber: InAppMessageStoreSubscriber? + private var userIdSubscriber: InAppMessageStoreSubscriber? private var queueTimer: Timer? init( @@ -34,41 +37,130 @@ class Gist: GistProvider { gistDelegate: GistDelegate, inAppMessageManager: InAppMessageManager, queueManager: QueueManager, - threadUtil: ThreadUtil + threadUtil: ThreadUtil, + sseLifecycleManager: SseLifecycleManager ) { self.logger = logger self.gistDelegate = gistDelegate self.inAppMessageManager = inAppMessageManager self.queueManager = queueManager self.threadUtil = threadUtil + self.sseLifecycleManager = sseLifecycleManager subscribeToInAppMessageState() + + // Start the SSE lifecycle manager to observe app foreground/background events + Task { + await sseLifecycleManager.start() + } } deinit { // Unsubscribe from in-app message state changes and release resources to stop polling // and prevent memory leaks. - if let subscriber = inAppMessageStoreSubscriber { + if let subscriber = pollIntervalSubscriber { + inAppMessageManager.unsubscribe(subscriber: subscriber) + } + if let subscriber = sseEnabledSubscriber { inAppMessageManager.unsubscribe(subscriber: subscriber) } - inAppMessageStoreSubscriber = nil + if let subscriber = userIdSubscriber { + inAppMessageManager.unsubscribe(subscriber: subscriber) + } + pollIntervalSubscriber = nil + sseEnabledSubscriber = nil + userIdSubscriber = nil + invalidateTimer() } private func subscribeToInAppMessageState() { - // Keep a strong reference to the subscriber to prevent deallocation and continue receiving updates - inAppMessageStoreSubscriber = { - let subscriber = InAppMessageStoreSubscriber { state in - self.setupPollingAndFetch(skipMessageFetch: true, pollingInterval: state.pollInterval) + // Subscribe to poll interval changes + pollIntervalSubscriber = { + let subscriber = InAppMessageStoreSubscriber { [weak self] state in + guard let self else { return } + // Only update polling if SSE is not active + if !state.shouldUseSse { + setupPollingAndFetch(skipMessageFetch: true, pollingInterval: state.pollInterval) + } } - // Subscribe to changes in `pollInterval` property of `InAppMessageState` inAppMessageManager.subscribe(keyPath: \.pollInterval, subscriber: subscriber) return subscriber }() + + // Subscribe to SSE flag changes (matching Android's subscribeToAttribute for sseEnabled) + sseEnabledSubscriber = { + let subscriber = InAppMessageStoreSubscriber { [weak self] state in + guard let self else { return } + handleSseEnabledChange(state: state) + } + inAppMessageManager.subscribe(keyPath: \.useSse, subscriber: subscriber) + logger.logWithModuleTag("Gist: Subscribed to SSE flag changes", level: .debug) + return subscriber + }() + + // Subscribe to user identification changes (matching Android's subscribeToAttribute for isUserIdentified) + userIdSubscriber = { + let subscriber = InAppMessageStoreSubscriber { [weak self] state in + guard let self else { return } + handleUserIdentificationChange(state: state) + } + inAppMessageManager.subscribe(keyPath: \.userId, subscriber: subscriber) + logger.logWithModuleTag("Gist: Subscribed to userId changes", level: .debug) + return subscriber + }() + } + + /// Handles SSE flag changes for polling control. + /// When SSE becomes active (enabled + identified user), stop polling. + /// When SSE becomes disabled, start polling. + private func handleSseEnabledChange(state: InAppMessageState) { + logger.logWithModuleTag( + "Gist: SSE flag changed - sseEnabled: \(state.useSse), isUserIdentified: \(state.isUserIdentified), shouldUseSse: \(state.shouldUseSse)", + level: .info + ) + + if state.shouldUseSse { + // SSE is now active - stop polling + logger.logWithModuleTag("Gist: SSE enabled for identified user - stopping polling timer", level: .info) + invalidateTimer() + } else if !state.useSse { + // SSE disabled - start polling + logger.logWithModuleTag("Gist: SSE disabled - starting polling with interval: \(state.pollInterval)s", level: .info) + setupPollingAndFetch(skipMessageFetch: false, pollingInterval: state.pollInterval) + } else { + // SSE enabled but user is anonymous - polling continues + logger.logWithModuleTag("Gist: SSE enabled but user anonymous - polling continues", level: .debug) + } + } + + /// Handles user identification changes for polling control. + /// When user becomes identified and SSE is enabled, stop polling (SSE will take over). + /// When user becomes anonymous but SSE flag is still enabled, start polling. + private func handleUserIdentificationChange(state: InAppMessageState) { + logger.logWithModuleTag( + "Gist: User identification changed - isUserIdentified: \(state.isUserIdentified), sseEnabled: \(state.useSse), shouldUseSse: \(state.shouldUseSse)", + level: .info + ) + + if state.shouldUseSse { + // User became identified and SSE is enabled - stop polling (SSE will take over) + logger.logWithModuleTag("Gist: User identified with SSE enabled - stopping polling (SSE will handle messages)", level: .info) + invalidateTimer() + } else if !state.isUserIdentified, state.useSse { + // User became anonymous but SSE flag is still enabled - start polling + // (SSE won't be used for anonymous users) + logger.logWithModuleTag("Gist: User became anonymous with SSE enabled - starting polling (SSE not used for anonymous users)", level: .info) + setupPollingAndFetch(skipMessageFetch: false, pollingInterval: state.pollInterval) + } else { + logger.logWithModuleTag("Gist: No polling action needed for user identification change", level: .debug) + } } private func invalidateTimer() { // Timer must be scheduled or modified on main. + let timerWasActive = queueTimer != nil + logger.logWithModuleTag("Gist: Invalidating polling timer (wasActive: \(timerWasActive))", level: .debug) threadUtil.runMain { self.queueTimer?.invalidate() self.queueTimer = nil @@ -141,7 +233,7 @@ class Gist: GistProvider { } private func setupPollingAndFetch(skipMessageFetch: Bool, pollingInterval: Double) { - logger.logWithModuleTag("Setting up polling with interval: \(pollingInterval) seconds and skipMessageFetch: \(skipMessageFetch)", level: .info) + logger.logWithModuleTag("Gist: Setting up polling timer - interval: \(pollingInterval)s, skipInitialFetch: \(skipMessageFetch)", level: .info) invalidateTimer() // Timer must be scheduled on the main thread @@ -153,6 +245,7 @@ class Gist: GistProvider { userInfo: nil, repeats: true ) + self.logger.logWithModuleTag("Gist: Polling timer started with interval: \(pollingInterval)s", level: .debug) } if !skipMessageFetch { @@ -167,16 +260,28 @@ class Gist: GistProvider { /// Also, the method must be called on main thread since it checks the application state. @objc func fetchUserMessages() { - logger.logWithModuleTag("Attempting to fetch user messages from remote service", level: .info) guard UIApplication.shared.applicationState != .background else { - logger.logWithModuleTag("Application in background, skipping queue check.", level: .info) + logger.logWithModuleTag("Gist: Application in background, skipping queue check", level: .debug) return } - logger.logWithModuleTag("Checking Gist queue service", level: .info) inAppMessageManager.fetchState { [weak self] state in guard let self else { return } + // Skip polling only if SSE should be used (enabled + user is identified) + // Anonymous users always use polling even if SSE flag is enabled + guard !state.shouldUseSse else { + logger.logWithModuleTag( + "Gist: Skipping polling - SSE active (sseEnabled: \(state.useSse), isUserIdentified: \(state.isUserIdentified))", + level: .debug + ) + return + } + + logger.logWithModuleTag( + "Gist: Polling for messages (sseEnabled: \(state.useSse), isUserIdentified: \(state.isUserIdentified))", + level: .info + ) fetchUserQueue(state: state) } } diff --git a/Sources/MessagingInApp/Gist/Managers/QueueManager.swift b/Sources/MessagingInApp/Gist/Managers/QueueManager.swift index 1ac0f0d39..efbe1fd82 100644 --- a/Sources/MessagingInApp/Gist/Managers/QueueManager.swift +++ b/Sources/MessagingInApp/Gist/Managers/QueueManager.swift @@ -43,6 +43,7 @@ class QueueManager { switch response { case .success(let (data, response)): self.updatePollingInterval(headers: response.allHeaderFields) + self.updateSseFlag(headers: response.allHeaderFields) self.logger.logWithModuleTag("Gist queue fetch response: \(response.statusCode)", level: .debug) switch response.statusCode { case 304: @@ -141,4 +142,26 @@ class QueueManager { inAppMessageManager.dispatch(action: .setPollingInterval(interval: newPollingInterval)) } } + + private func updateSseFlag(headers: [AnyHashable: Any]) { + // Check for SSE flag in headers + if let sseHeaderValue = headers["x-cio-use-sse"] as? String { + logger.logWithModuleTag("X-CIO-Use-SSE header found with value: '\(sseHeaderValue)'", level: .info) + let useSse = sseHeaderValue.lowercased() == "true" + + inAppMessageManager.fetchState { [weak self] state in + guard let self = self else { return } + + // Only update if the value has changed + if state.useSse != useSse { + logger.logWithModuleTag("SSE flag changing from \(state.useSse) to \(useSse)", level: .info) + inAppMessageManager.dispatch(action: .setSseEnabled(enabled: useSse)) + } else { + logger.logWithModuleTag("SSE flag unchanged, remains: \(useSse)", level: .debug) + } + } + } else { + logger.logWithModuleTag("X-CIO-Use-SSE header not present in response", level: .debug) + } + } } diff --git a/Sources/MessagingInApp/Gist/Network/NetworkSettings.swift b/Sources/MessagingInApp/Gist/Network/NetworkSettings.swift index a6083d04e..254bb868c 100644 --- a/Sources/MessagingInApp/Gist/Network/NetworkSettings.swift +++ b/Sources/MessagingInApp/Gist/Network/NetworkSettings.swift @@ -2,22 +2,26 @@ protocol NetworkSettings { var queueAPI: String { get } var engineAPI: String { get } var renderer: String { get } + var sseAPI: String { get } } struct NetworkSettingsProduction: NetworkSettings { let queueAPI = "https://consumer.inapp.customer.io" let engineAPI = "https://engine.api.gist.build" let renderer = "https://renderer.gist.build/3.0" + let sseAPI = "https://realtime.inapp.customer.io/api/v3/sse" } struct NetworkSettingsDevelopment: NetworkSettings { let queueAPI = "https://consumer.dev.inapp.customer.io" let engineAPI = "https://engine.api.dev.gist.build" let renderer = "https://renderer.gist.build/3.0" + let sseAPI = "https://realtime.inapp.customer.io/api/v3/sse" } struct NetworkSettingsLocal: NetworkSettings { let queueAPI = "http://queue.api.local.gist.build:86" let engineAPI = "http://engine.api.local.gist.build:82" let renderer = "http://app.local.gist.build:8080/web" + let sseAPI = "http://realtime.api.local.gist.build:86/api/v3/sse" } diff --git a/Sources/MessagingInApp/Gist/Network/SSE/AsyncStreamBackport.swift b/Sources/MessagingInApp/Gist/Network/SSE/AsyncStreamBackport.swift new file mode 100644 index 000000000..8ec0f2fcf --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/AsyncStreamBackport.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Backport of `AsyncStream.makeStream()` for iOS 13+. +/// +/// `AsyncStream.makeStream()` was introduced in iOS 17 and provides a way to create +/// an AsyncStream and access its continuation separately. This is useful when you need +/// to set up resources that require the continuation before returning the stream. +/// +/// This backport provides the same functionality for older iOS versions. +enum AsyncStreamBackport { + /// Creates an AsyncStream and returns both the stream and its continuation separately. + /// + /// This allows setup code to access the continuation before the stream is consumed, + /// enabling synchronous setup patterns that avoid race conditions. + /// + /// Example: + /// ```swift + /// func connect() -> AsyncStream { + /// let (stream, continuation) = AsyncStreamBackport.makeStream(of: Event.self) + /// + /// // Use continuation immediately for setup + /// let handler = EventHandler(continuation: continuation) + /// self.connection = Connection(handler: handler) + /// self.connection.start() + /// + /// return stream + /// } + /// ``` + /// + /// - Parameters: + /// - elementType: The type of elements in the stream. + /// - bufferingPolicy: The buffering policy for the stream. Defaults to `.unbounded`. + /// - Returns: A tuple containing the stream and its continuation. + static func makeStream( + of elementType: Element.Type = Element.self, + bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded + ) -> (stream: AsyncStream, continuation: AsyncStream.Continuation) { + // Capture the continuation from the closure using an optional variable + var continuation: AsyncStream.Continuation? + + let stream = AsyncStream(bufferingPolicy: bufferingPolicy) { cont in + // This closure is called synchronously during AsyncStream initialization + continuation = cont + } + + // By the time AsyncStream's init returns, the closure has executed + // and continuation is guaranteed to be set + guard let continuation = continuation else { + // This should never happen - the closure is called synchronously + fatalError("AsyncStream continuation was not set during initialization") + } + + return (stream, continuation) + } +} diff --git a/Sources/MessagingInApp/Gist/Network/SSE/HeartbeatTimer.swift b/Sources/MessagingInApp/Gist/Network/SSE/HeartbeatTimer.swift new file mode 100644 index 000000000..0d9d4e291 --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/HeartbeatTimer.swift @@ -0,0 +1,129 @@ +import CioInternalCommon +import Foundation + +// sourcery: InjectRegisterShared = "HeartbeatTimerProtocol" +/// Heartbeat timer that monitors server heartbeats and emits timeout events +/// when the server stops sending heartbeats within the expected timeframe. +/// +/// Uses a connection generation ID to ensure callbacks and resets only affect +/// the correct connection, preventing stale timeouts from triggering on new connections. +/// +/// Corresponds to Android's `HeartbeatTimer` class. +actor HeartbeatTimer: HeartbeatTimerProtocol { + /// Default heartbeat timeout in seconds (matches Android's DEFAULT_HEARTBEAT_TIMEOUT_MS / 1000) + static let defaultHeartbeatTimeoutSeconds: TimeInterval = 30 + + /// Buffer added to heartbeat timeout to account for network delays + static let heartbeatBufferSeconds: TimeInterval = 5 + + /// Initial timeout used when connection opens before receiving server heartbeat config + static let initialTimeoutSeconds: TimeInterval = defaultHeartbeatTimeoutSeconds + heartbeatBufferSeconds + + /// Maximum timeout in seconds to prevent overflow when converting to nanoseconds + /// UInt64.max nanoseconds ≈ 584 years, but we cap at 1 hour for practical purposes + private static let maxTimeoutSeconds: TimeInterval = 3600 + + private let logger: Logger + private var onTimeout: ((UInt64) async -> Void)? + private var currentTimerTask: Task? + private var activeGeneration: UInt64 = 0 + + /// Creates a HeartbeatTimer + /// - Parameter logger: Logger for debug output + init(logger: Logger) { + self.logger = logger + } + + /// Sets the timeout callback. The callback receives the connection generation ID. + /// - Parameter callback: Callback invoked when the heartbeat timer expires, with the generation ID + func setCallback(_ callback: @escaping (UInt64) async -> Void) { + onTimeout = callback + } + + /// Starts the heartbeat timer with the specified timeout for a specific connection generation. + /// + /// If a timer is already running, it will be cancelled and replaced with the new timer. + /// This is the expected behavior when receiving heartbeat events from the server. + /// + /// - Parameters: + /// - timeoutSeconds: Timeout in seconds after which the timer will expire + /// - generation: The connection generation this timer is for + func startTimer(timeoutSeconds: TimeInterval, generation: UInt64) { + // Cancel existing timer if running + if currentTimerTask != nil { + logger.logWithModuleTag("[HeartbeatTimer] Cancelling previous timer", level: .debug) + currentTimerTask?.cancel() + } + + activeGeneration = generation + + // Clamp timeout to valid range to prevent overflow when converting to nanoseconds + let clampedTimeout = min(max(timeoutSeconds, 0), Self.maxTimeoutSeconds) + let nanoseconds = UInt64(clampedTimeout * 1000000000) + + logger.logWithModuleTag("[HeartbeatTimer] Starting timer with \(clampedTimeout)s timeout (generation \(generation))", level: .debug) + + currentTimerTask = Task { [weak self, generation, clampedTimeout] in + do { + // Task.sleep with nanoseconds required for iOS 13+ compatibility + try await Task.sleep(nanoseconds: nanoseconds) + + // Check cancellation after sleep completes + guard !Task.isCancelled else { + await self?.logTimerCancelled(generation: generation) + return + } + + // Fire callback with generation - manager will verify it's still current + await self?.fireCallbackIfActive(generation: generation, timeoutSeconds: clampedTimeout) + } catch is CancellationError { + // Task was cancelled during sleep - expected behavior + await self?.logTimerCancelled(generation: generation) + } catch { + // Unexpected error - log but don't crash + await self?.logUnexpectedError(error) + } + } + } + + /// Resets the heartbeat timer for a specific generation. + /// Only cancels the timer if the generation matches the active one. + /// + /// - Parameter generation: The connection generation to reset + func reset(generation: UInt64) { + guard generation == activeGeneration else { + logger.logWithModuleTag("[HeartbeatTimer] Skipping reset - generation mismatch (requested \(generation) vs active \(activeGeneration))", level: .debug) + return + } + + if currentTimerTask != nil { + logger.logWithModuleTag("[HeartbeatTimer] Reset called - cancelling active timer (generation \(generation))", level: .debug) + currentTimerTask?.cancel() + currentTimerTask = nil + } else { + logger.logWithModuleTag("[HeartbeatTimer] Reset called - no active timer (generation \(generation))", level: .debug) + } + } + + // MARK: - Private Methods + + private func fireCallbackIfActive(generation: UInt64, timeoutSeconds: TimeInterval) async { + // Verify this timer is still for the active generation + guard generation == activeGeneration else { + logger.logWithModuleTag("[HeartbeatTimer] Stale timer expired (generation \(generation) vs active \(activeGeneration)), ignoring", level: .debug) + return + } + + logger.logWithModuleTag("[HeartbeatTimer] ⚠️ Timer EXPIRED after \(timeoutSeconds)s - triggering timeout callback (generation \(generation))", level: .error) + await onTimeout?(generation) + logger.logWithModuleTag("[HeartbeatTimer] Timeout callback completed", level: .debug) + } + + private func logTimerCancelled(generation: UInt64) { + logger.logWithModuleTag("[HeartbeatTimer] Timer cancelled (generation \(generation))", level: .debug) + } + + private func logUnexpectedError(_ error: Error) { + logger.logWithModuleTag("[HeartbeatTimer] Unexpected error: \(error.localizedDescription)", level: .error) + } +} diff --git a/Sources/MessagingInApp/Gist/Network/SSE/HeartbeatTimerProtocol.swift b/Sources/MessagingInApp/Gist/Network/SSE/HeartbeatTimerProtocol.swift new file mode 100644 index 000000000..57d0ad752 --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/HeartbeatTimerProtocol.swift @@ -0,0 +1,23 @@ +import CioInternalCommon +import Foundation + +/// Protocol for heartbeat timer to enable testing with mocks. +/// Abstracts timer functionality so `SseConnectionManager` can be tested +/// without real timing delays. +protocol HeartbeatTimerProtocol: AutoMockable { + /// Sets the timeout callback. The callback receives the connection generation ID. + /// - Parameter callback: Callback invoked when the heartbeat timer expires, with the generation ID + func setCallback(_ callback: @escaping (UInt64) async -> Void) async + + /// Starts the heartbeat timer with the specified timeout for a specific connection generation. + /// If a timer is already running, it will be cancelled and replaced with the new timer. + /// - Parameters: + /// - timeoutSeconds: Timeout in seconds after which the timer will expire + /// - generation: The connection generation this timer is for + func startTimer(timeoutSeconds: TimeInterval, generation: UInt64) async + + /// Resets the heartbeat timer for a specific generation. + /// Only cancels the timer if the generation matches the active one. + /// - Parameter generation: The connection generation to reset + func reset(generation: UInt64) async +} diff --git a/Sources/MessagingInApp/Gist/Network/SSE/RetryDecision.swift b/Sources/MessagingInApp/Gist/Network/SSE/RetryDecision.swift new file mode 100644 index 000000000..6d536ced8 --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/RetryDecision.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Represents decisions made by SseRetryHelper about retry behavior. +/// This is emitted to the connection manager via an AsyncStream. +/// All delays are handled inside SseRetryHelper, so only RetryNow or failure cases are emitted. +/// Corresponds to Android's `RetryDecision` sealed class. +enum RetryDecision: Equatable { + /// Retry now (all delays have been handled by SseRetryHelper) + /// - Parameter attemptCount: The current retry attempt number (1-based) + case retryNow(attemptCount: Int) + + /// Maximum retries reached, fallback to polling + case maxRetriesReached + + /// Non-retryable error, fallback to polling + case retryNotPossible +} diff --git a/Sources/MessagingInApp/Gist/Network/SSE/Sleeper.swift b/Sources/MessagingInApp/Gist/Network/SSE/Sleeper.swift new file mode 100644 index 000000000..bca978a05 --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/Sleeper.swift @@ -0,0 +1,20 @@ +import CioInternalCommon +import Foundation + +/// Protocol for abstracting sleep/delay functionality. +/// Enables testing of time-dependent code without real delays. +protocol Sleeper: AutoMockable { + /// Sleeps for the specified duration. + /// - Parameter seconds: The number of seconds to sleep + /// - Throws: CancellationError if the task is cancelled during sleep + func sleep(seconds: TimeInterval) async throws +} + +// sourcery: InjectRegisterShared = "Sleeper" +/// Production implementation of Sleeper using Task.sleep. +struct RealSleeper: Sleeper { + func sleep(seconds: TimeInterval) async throws { + let nanoseconds = UInt64(seconds * 1000000000) + try await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/Sources/MessagingInApp/Gist/Network/SSE/SseConnectionManager.swift b/Sources/MessagingInApp/Gist/Network/SSE/SseConnectionManager.swift new file mode 100644 index 000000000..a2405afa9 --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/SseConnectionManager.swift @@ -0,0 +1,560 @@ +// swiftlint:disable file_length type_body_length +import CioInternalCommon +import Foundation + +/// Protocol for SSE connection management, enabling testability. +protocol SseConnectionManagerProtocol: AutoMockable { + /// Starts an SSE connection to the queue consumer API. + func startConnection() async + + /// Stops the current SSE connection. + func stopConnection() async +} + +// sourcery: InjectRegisterShared = "SseConnectionManagerProtocol" +// sourcery: InjectSingleton +/// Manages SSE (Server-Sent Events) connections for real-time in-app message delivery. +/// Handles connection lifecycle, event parsing, and automatic retry behavior on connection failures. +/// +/// Corresponds to Android's `SseConnectionManager` class. +/// +/// ## Connection Generation +/// +/// This implementation uses a connection generation ID to eliminate race conditions. +/// Each new connection attempt increments the generation counter. All cleanup operations, +/// callbacks, and event handlers carry this generation ID and are ignored if it doesn't +/// match the current active connection. This prevents: +/// - `stopConnection()` cleanup from killing a new connection that started during await +/// - Stale heartbeat timeouts from triggering on new connections +/// - Stale retry decisions from affecting new connections +/// +/// ## Connection state transitions: +/// - DISCONNECTED -> CONNECTING (startConnection) +/// - CONNECTING -> CONNECTED (ConnectionOpenEvent/CONNECTED event from server) +/// - CONNECTED -> DISCONNECTING (stopConnection) +/// - CONNECTING/CONNECTED -> DISCONNECTED (ConnectionFailedEvent/ConnectionClosedEvent from SseService) +/// - DISCONNECTING -> DISCONNECTED (stopConnection completes, or ConnectionClosedEvent if disconnect() was called) +/// +/// ## Task lifecycle: +/// +/// **streamTask (Connection Event Collector)**: +/// - Starts: When `startConnection()` is called +/// - Purpose: Collects SSE events from `SseService` and processes them +/// - Cancelled: In `stopConnection()` or when starting a new connection attempt +/// +/// **retryTask (Retry Decision Collector)**: +/// - Starts: When `subscribeToRetryDecisions()` is called (during `startConnection()`) +/// - Purpose: Collects retry decisions from `SseRetryHelper` and acts on them (start connection, fallback to polling) +/// - Cancelled: In `stopConnection()` when explicitly stopping the connection (matches Android's behavior) +/// +/// **HeartbeatTimer**: +/// - Started: In `setupSuccessfulConnection()` when connection is confirmed (ConnectionOpenEvent/CONNECTED event) +/// - Reset: In `handleConnectionFailure()`, `cleanupForReconnect()`, `handleCompleteFailure()`, and `ConnectionClosedEvent` handler +/// - Purpose: Monitors server heartbeats and triggers timeout if no heartbeat received within the timeout period +actor SseConnectionManager: SseConnectionManagerProtocol { + private let logger: Logger + private let inAppMessageManager: InAppMessageManager + private let sseService: SseServiceProtocol + private let retryHelper: SseRetryHelperProtocol + private let heartbeatTimer: HeartbeatTimerProtocol + + /// The current connection generation. Incremented each time a new connection starts. + /// Used to prevent stale operations from affecting new connections. + private var activeConnectionGeneration: UInt64 = 0 + + private var connectionState: SseConnectionState = .disconnected + private var streamTask: Task? + private var retryTask: Task? + + init( + logger: Logger, + inAppMessageManager: InAppMessageManager, + sseService: SseServiceProtocol, + retryHelper: SseRetryHelperProtocol, + heartbeatTimer: HeartbeatTimerProtocol + ) { + self.logger = logger + self.inAppMessageManager = inAppMessageManager + self.sseService = sseService + self.retryHelper = retryHelper + self.heartbeatTimer = heartbeatTimer + + logger.logWithModuleTag("SseConnectionManager initialized", level: .debug) + } + + /// Sets up the heartbeat timer callback. + /// Called automatically by startConnection() - idempotent, safe to call multiple times. + private var heartbeatCallbackSet = false + private func setHeartbeatCallback() async { + guard !heartbeatCallbackSet else { return } + heartbeatCallbackSet = true + + logger.logWithModuleTag("SSE Manager: Setting up heartbeat timeout callback", level: .debug) + await heartbeatTimer.setCallback { [weak self] generation in + guard let self = self else { return } + await self.handleHeartbeatTimeout(generation: generation) + } + } + + // MARK: - Public API + + /// Starts an SSE connection to the queue consumer API. + /// This method is idempotent - calling it multiple times while connected/connecting is safe. + /// + /// Fetches the current state from InAppMessageManager to establish the connection. + /// Allows connection attempts from DISCONNECTING state - the old connection's event collection + /// will be cancelled, so we won't receive disconnected events from the old connection. + func startConnection() async { + logger.logWithModuleTag("SSE Manager: startConnection called", level: .info) + + // Ensure heartbeat callback is set up (idempotent - safe to call multiple times) + await setHeartbeatCallback() + + // Fetch current state from manager + let state = await inAppMessageManager.state + logger.logWithModuleTag("SSE Manager: useSse=\(state.useSse), userId=\(state.userId ?? "nil"), anonymousId=\(state.anonymousId ?? "nil")", level: .debug) + + // Check if already active + if connectionState == .connecting || connectionState == .connected { + logger.logWithModuleTag("SSE Manager: Connection already active (state: \(connectionState.description))", level: .debug) + return + } + + // Increment generation for new connection - this must happen BEFORE any await + // so that stopConnection() captures the correct generation + activeConnectionGeneration += 1 + let generation = activeConnectionGeneration + + logger.logWithModuleTag("SSE Manager: Starting connection (generation \(generation))", level: .info) + + // Cancel any existing stream task + streamTask?.cancel() + + // Update state to connecting + updateConnectionState(.connecting) + + // Set the active generation in retry helper + await retryHelper.setActiveGeneration(generation) + + // Ensure retry decision collector is running with fresh stream + await subscribeToRetryDecisions() + + // Start the connection + var newTask: Task? + + newTask = Task { [weak self, generation] in + guard let self = self else { return } + guard let task = newTask else { return } + await self.executeConnectionAttempt(task: task, generation: generation) + } + + streamTask = newTask + } + + /// Stops the active SSE connection. + /// This method is idempotent - calling it multiple times is safe. + /// + /// The cleanup operations include the connection generation, so they only affect + /// the connection that was active when stopConnection() was called. If a new connection + /// starts during the await points, it won't be affected by this cleanup. + func stopConnection() async { + // Capture the generation we're stopping BEFORE any state changes + let stoppingGeneration = activeConnectionGeneration + + logger.logWithModuleTag("SSE Manager: stopConnection called (stopping generation \(stoppingGeneration))", level: .info) + + updateConnectionState(.disconnecting) + + // Cancel all tasks + streamTask?.cancel() + streamTask = nil + retryTask?.cancel() + retryTask = nil + + // Reset helpers with generation - they will ignore if generation doesn't match + await retryHelper.resetRetryState(generation: stoppingGeneration) + await heartbeatTimer.reset(generation: stoppingGeneration) + await sseService.disconnect(connectionId: stoppingGeneration) + + // Only update state if still the same generation (no new connection started during awaits) + guard activeConnectionGeneration == stoppingGeneration else { + logger.logWithModuleTag("SSE Manager: New connection started during stop (gen \(activeConnectionGeneration)), skipping final state update", level: .debug) + return + } + + updateConnectionState(.disconnected) + + logger.logWithModuleTag("SSE Manager: Connection stopped", level: .info) + } + + // MARK: - Connection Execution + + /// Executes the actual connection attempt and handles failures. + /// - Parameters: + /// - task: The task reference for this connection attempt + /// - generation: The connection generation this attempt belongs to + private func executeConnectionAttempt(task: Task, generation: UInt64) async { + // Verify generation is still current before connecting + guard generation == activeConnectionGeneration else { + logger.logWithModuleTag("SSE Manager: Stale connection attempt (generation \(generation) vs \(activeConnectionGeneration)), aborting", level: .debug) + return + } + + // Fetch current state from manager + let state = await inAppMessageManager.state + + let eventStream = await sseService.connect(state: state, connectionId: generation) + + logger.logWithModuleTag("SSE Manager: Connected with connectionId \(generation)", level: .debug) + + // Process events from stream + for await event in eventStream { + // Check for cancellation before processing each event + guard !Task.isCancelled else { + logger.logWithModuleTag("SSE Manager: Connection task cancelled", level: .debug) + return + } + + // Verify generation is still current + guard generation == activeConnectionGeneration else { + logger.logWithModuleTag("SSE Manager: Event received for stale generation \(generation), ignoring", level: .debug) + return + } + + await handleEvent(event, generation: generation) + } + + // Stream ended - pass task and generation reference to verify it's still current + handleStreamEnded(task: task, generation: generation) + } + + // MARK: - Event Handlers + + private func handleEvent(_ event: SseEvent, generation: UInt64) async { + switch event { + case .connectionOpen: + await handleConnectionOpen(generation: generation) + + case .serverEvent(let serverEvent): + await handleServerEvent(serverEvent, generation: generation) + + case .connectionFailed(let error): + await handleConnectionFailed(error, generation: generation) + + case .connectionClosed: + await handleConnectionClosed(generation: generation) + } + } + + private func handleStreamEnded(task: Task, generation: UInt64) { + // Only proceed if this is still the current stream task and generation + guard streamTask == task, generation == activeConnectionGeneration else { + logger.logWithModuleTag("SSE Manager: Stale stream ended (gen \(generation) vs \(activeConnectionGeneration)), ignoring", level: .debug) + return + } + + logger.logWithModuleTag("SSE Manager: Event stream ended normally", level: .info) + + // Defensive cleanup - ensure timer is reset and state is correct + Task { + await heartbeatTimer.reset(generation: generation) + } + updateConnectionState(.disconnected) + + streamTask = nil + } + + // MARK: - Connection Lifecycle Handlers + + private func handleConnectionOpen(generation: UInt64) async { + guard generation == activeConnectionGeneration else { return } + + logger.logWithModuleTag("SSE Manager: ✓ Connection opened (generation \(generation))", level: .info) + await setupSuccessfulConnection(generation: generation) + } + + private func handleConnectionClosed(generation: UInt64) async { + guard generation == activeConnectionGeneration else { return } + + logger.logWithModuleTag("SSE Manager: Connection closed", level: .info) + + // Check if this is an unexpected close (still in connected/connecting state) + // vs a close following an error (already in disconnected state from handleConnectionFailed) + // vs an intentional stop (in disconnecting state from stopConnection) + // + // On iOS, URLSession reports server-initiated closes as didCompleteWithError(error: nil), + // which triggers onClosed() without going through the error handler. This differs from + // Android/OkHttp where such closes throw StreamClosedByServerException and go through + // the error path. We need to treat unexpected closes as retriable errors to match + // Android's behavior. + let wasUnexpectedClose = connectionState == .connecting || connectionState == .connected + + // Reset heartbeat timer when connection closes + await heartbeatTimer.reset(generation: generation) + + updateConnectionState(.disconnected) + + // If connection closed unexpectedly (not following an error or intentional stop), + // treat it as a retriable network error + if wasUnexpectedClose { + logger.logWithModuleTag("SSE Manager: ⚠️ Unexpected connection close - treating as retriable error", level: .info) + let error = SseError.networkError(message: "Connection closed unexpectedly", underlyingError: nil) + await retryHelper.scheduleRetry(error: error, generation: generation) + } + } + + private func handleConnectionFailed(_ error: SseError, generation: UInt64) async { + // Verify generation is still current + guard generation == activeConnectionGeneration else { + logger.logWithModuleTag("SSE Manager: Ignoring failure for stale generation \(generation)", level: .debug) + return + } + + // Guard against duplicate failure handling for the same connection + guard connectionState == .connecting || connectionState == .connected else { + logger.logWithModuleTag("SSE Manager: Ignoring duplicate failure - already in \(connectionState.description) state", level: .debug) + return + } + + logger.logWithModuleTag("SSE Manager: ✗ Connection failed: \(error.message), retryable: \(error.shouldRetry)", level: .error) + logger.logWithModuleTag("SSE Manager: Error type: \(error.errorType)", level: .debug) + + // Cleanup between retries + updateConnectionState(.disconnected) + await heartbeatTimer.reset(generation: generation) + + // Schedule retry (or fallback if non-retryable) + logger.logWithModuleTag("SSE Manager: Requesting retry decision from helper...", level: .debug) + await retryHelper.scheduleRetry(error: error, generation: generation) + } + + // MARK: - Server Event Handlers + + private func handleServerEvent(_ event: ServerEvent, generation: UInt64) async { + guard generation == activeConnectionGeneration else { return } + + logger.logWithModuleTag("SSE Manager: ← Server event '\(event.eventType)'", level: .info) + logger.logWithModuleTag("SSE Manager: Event data: \(event.data.prefix(100))", level: .debug) + + switch event.eventType { + case .connected: + logger.logWithModuleTag("SSE Manager: ✓ Server acknowledged connection", level: .info) + await setupSuccessfulConnection(generation: generation) + + case .heartbeat: + await handleHeartbeatEvent(event, generation: generation) + + case .messages: + handleMessagesEvent(event) + + case .ttlExceeded: + logger.logWithModuleTag("SSE Manager: TTL exceeded - reconnecting", level: .info) + await cleanupForReconnect(generation: generation) + + // Guard against task cancellation (e.g., if stopConnection() was called during cleanup) + guard !Task.isCancelled else { + logger.logWithModuleTag("SSE Manager: Task cancelled during TTL cleanup, skipping reconnection", level: .debug) + return + } + + // Reconnect (will fetch fresh state from manager) + await startConnection() + + case .unknown: + logger.logWithModuleTag("SSE Manager: Unknown server event type '\(event.rawEventType ?? "nil")', ignoring", level: .debug) + } + } + + private func handleHeartbeatEvent(_ event: ServerEvent, generation: UInt64) async { + guard generation == activeConnectionGeneration else { return } + + // Use server-provided interval or default if parsing failed (nil for edge cases like empty data, malformed JSON) + let heartbeatInterval = event.heartbeatIntervalSeconds ?? HeartbeatTimer.defaultHeartbeatTimeoutSeconds + let timeoutWithBuffer = heartbeatInterval + HeartbeatTimer.heartbeatBufferSeconds + + logger.logWithModuleTag("SSE Manager: Heartbeat received (interval: \(heartbeatInterval)s, timeout: \(timeoutWithBuffer)s)", level: .debug) + + // Restart heartbeat timer with the parsed interval + buffer + await heartbeatTimer.startTimer(timeoutSeconds: timeoutWithBuffer, generation: generation) + } + + private func handleMessagesEvent(_ event: ServerEvent) { + logger.logWithModuleTag("SSE Manager: Message event received", level: .info) + + guard let messages = event.messages, !messages.isEmpty else { + logger.logWithModuleTag("SSE Manager: No messages in event or failed to parse", level: .debug) + return + } + + logger.logWithModuleTag("SSE Manager: ✓ Received \(messages.count) message(s) from SSE", level: .info) + + // Dispatch to message queue processor (same as polling does) + inAppMessageManager.dispatch(action: .processMessageQueue(messages: messages)) + } + + // MARK: - Heartbeat Timeout Handler + + /// Handles heartbeat timeout - called when no heartbeat is received within the expected timeframe + /// - Parameter generation: The connection generation this timeout is for + private func handleHeartbeatTimeout(generation: UInt64) async { + // Verify generation is still current + guard generation == activeConnectionGeneration else { + logger.logWithModuleTag("SSE Manager: Ignoring stale heartbeat timeout (gen \(generation) vs \(activeConnectionGeneration))", level: .debug) + return + } + + logger.logWithModuleTag("SSE Manager: ⚠️ Heartbeat timeout - triggering retry logic", level: .error) + + // Treat timeout as a retryable error + await handleConnectionFailed(.timeoutError, generation: generation) + } + + // MARK: - Retry Logic + + /// Sets up subscription to retry decisions from SseRetryHelper. + /// Creates a fresh stream for each connection cycle to avoid AsyncStream exhaustion issues. + private func subscribeToRetryDecisions() async { + // Cancel existing retry task - it will exit when old stream is finished + retryTask?.cancel() + retryTask = nil + + logger.logWithModuleTag("SSE Manager: Setting up retry decision subscription", level: .debug) + + // Get FRESH stream - this also finishes any old stream, causing old iterators to exit + let stream = await retryHelper.createNewRetryStream() + + retryTask = Task { [weak self] in + guard let self = self else { return } + + await self.logger.logWithModuleTag("SSE Manager: Retry task started, iterating stream...", level: .debug) + + for await(decision, generation) in stream { + await self.logger.logWithModuleTag("SSE Manager: Received retry decision: \(decision) (generation \(generation))", level: .debug) + + // Check for cancellation + guard !Task.isCancelled else { + await self.logRetryCancelled() + return + } + + await self.handleRetryDecision(decision, generation: generation) + } + + await self.logger.logWithModuleTag("SSE Manager: Retry stream ended", level: .debug) + } + } + + private func handleRetryDecision(_ decision: RetryDecision, generation: UInt64) async { + // Verify generation is still current + guard generation == activeConnectionGeneration else { + logger.logWithModuleTag("SSE Manager: Ignoring stale retry decision (gen \(generation) vs \(activeConnectionGeneration))", level: .debug) + return + } + + switch decision { + case .retryNow(let attemptCount): + logger.logWithModuleTag("SSE Manager: Retrying connection (attempt \(attemptCount)/\(SseRetryHelper.maxRetryCount))", level: .info) + + // Cancel existing stream task before retry + streamTask?.cancel() + streamTask = nil + + // Update state and start new connection (will fetch fresh state from manager) + updateConnectionState(.connecting) + + // Start new connection attempt with same generation + var newTask: Task? + + newTask = Task { [weak self, generation] in + guard let self = self else { return } + guard let task = newTask else { return } + await self.executeConnectionAttempt(task: task, generation: generation) + } + + streamTask = newTask + + case .maxRetriesReached: + logger.logWithModuleTag("SSE Manager: Max retries reached - falling back to polling", level: .error) + await handleCompleteFailure(generation: generation) + + case .retryNotPossible: + logger.logWithModuleTag("SSE Manager: Non-retryable error - falling back to polling", level: .error) + await handleCompleteFailure(generation: generation) + } + } + + /// Handles complete failure: cleans up and falls back to polling. + /// This is called when max retries are reached or error is non-retryable. + private func handleCompleteFailure(generation: UInt64) async { + guard generation == activeConnectionGeneration else { return } + + logger.logWithModuleTag("SSE Manager: Complete failure - falling back to polling", level: .error) + + // Cleanup on complete failure + updateConnectionState(.disconnected) + await heartbeatTimer.reset(generation: generation) + await retryHelper.resetRetryState(generation: generation) + + // Re-check generation after await points before taking global action + // (a new connection may have started during the cleanup awaits) + guard generation == activeConnectionGeneration else { + logger.logWithModuleTag("SSE Manager: New connection started during cleanup, skipping SSE disable", level: .debug) + return + } + + // Fallback to polling by disabling SSE + inAppMessageManager.dispatch(action: .setSseEnabled(enabled: false)) + } + + /// Cleans up state before reconnecting (e.g., after TTL_EXCEEDED). + /// Resets connection state, heartbeat timer, and retry state. + private func cleanupForReconnect(generation: UInt64) async { + guard generation == activeConnectionGeneration else { return } + + updateConnectionState(.disconnected) + await heartbeatTimer.reset(generation: generation) + await retryHelper.resetRetryState(generation: generation) + // Don't cancel retryTask - it should persist to handle future retries + } + + /// Sets up state after successful connection: resets retry state and starts heartbeat timer. + /// This is called when connection is confirmed (ConnectionOpenEvent or CONNECTED event). + private func setupSuccessfulConnection(generation: UInt64) async { + guard generation == activeConnectionGeneration else { return } + + updateConnectionState(.connected) + await retryHelper.resetRetryState(generation: generation) + + // Start heartbeat timer with initial timeout (default + buffer) + await heartbeatTimer.startTimer(timeoutSeconds: HeartbeatTimer.initialTimeoutSeconds, generation: generation) + } + + // MARK: - State Management + + private func updateConnectionState(_ newState: SseConnectionState) { + guard newState != connectionState else { return } + + let oldState = connectionState.description + logger.logWithModuleTag("SSE Manager: State transition: \(oldState) → \(newState.description)", level: .info) + connectionState = newState + + switch newState { + case .disconnected: + logger.logWithModuleTag("SSE Manager: Connection is now closed", level: .info) + case .connecting: + logger.logWithModuleTag("SSE Manager: Connection initiated, waiting for response", level: .info) + case .connected: + logger.logWithModuleTag("SSE Manager: ✓ Connection established, listening for events", level: .info) + case .disconnecting: + logger.logWithModuleTag("SSE Manager: Connection stopping...", level: .info) + } + } + + // MARK: - Private Logging + + private func logRetryCancelled() { + logger.logWithModuleTag("SSE Manager: Retry task cancelled", level: .debug) + } +} + +// swiftlint:enable file_length type_body_length diff --git a/Sources/MessagingInApp/Gist/Network/SSE/SseConnectionState.swift b/Sources/MessagingInApp/Gist/Network/SSE/SseConnectionState.swift new file mode 100644 index 000000000..f186f9686 --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/SseConnectionState.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Represents the current state of the SSE connection. +/// Corresponds to Android's `SseConnectionState` enum. +/// +/// Connection state transitions: +/// - DISCONNECTED -> CONNECTING (startConnection) +/// - CONNECTING -> CONNECTED (ConnectionOpenEvent/CONNECTED event from server) +/// - CONNECTED -> DISCONNECTING (stopConnection) +/// - CONNECTING/CONNECTED -> DISCONNECTED (ConnectionFailedEvent/ConnectionClosedEvent from SseService) +/// - DISCONNECTING -> DISCONNECTED (stopConnection completes) +enum SseConnectionState: Equatable { + case disconnected + case connecting + case connected + case disconnecting + + var description: String { + switch self { + case .disconnected: + return "disconnected" + case .connecting: + return "connecting" + case .connected: + return "connected" + case .disconnecting: + return "disconnecting" + } + } +} diff --git a/Sources/MessagingInApp/Gist/Network/SSE/SseEventParser.swift b/Sources/MessagingInApp/Gist/Network/SSE/SseEventParser.swift new file mode 100644 index 000000000..b5a7af92a --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/SseEventParser.swift @@ -0,0 +1,295 @@ +import Foundation + +// MARK: - SSE Event (matches Android's sealed interface) + +/// Represents an SSE event from the server connection. +/// Aligns with Android's `SseEvent` sealed interface. +enum SseEvent: Equatable { + /// Connection opened event (emitted by library's onOpen callback). + /// Corresponds to Android's `ConnectionOpenEvent`. + case connectionOpen + + /// Server event with type and data. + /// Corresponds to Android's `ServerEvent`. + case serverEvent(ServerEvent) + + /// Connection failed event with error details. + /// Corresponds to Android's `ConnectionFailedEvent`. + case connectionFailed(SseError) + + /// Connection closed event (emitted by library's onClosed callback). + /// Corresponds to Android's `ConnectionClosedEvent`. + case connectionClosed +} + +// MARK: - Server Event + +/// Represents a server event with type and data. +/// Corresponds to Android's `ServerEvent` data class. +struct ServerEvent: Equatable { + /// Server event types (matches Android's ServerEvent companion object constants) + enum EventType: String, Equatable, CustomStringConvertible { + case connected + case heartbeat + case messages + case ttlExceeded = "ttl_exceeded" + case unknown + + var description: String { rawValue } + + init(rawValue: String) { + switch rawValue { + case "connected": self = .connected + case "heartbeat": self = .heartbeat + case "messages", "": self = .messages // Empty/nil defaults to messages per SSE spec + case "ttl_exceeded": self = .ttlExceeded + default: self = .unknown + } + } + } + + let eventType: EventType + let data: String + let id: String? // Event ID for Last-Event-ID tracking + + /// Parsed messages (only populated for message events) + let messages: [Message]? + + /// Parsed heartbeat interval in seconds. + /// Corresponds to Android's `parseHeartbeatTimeout` function. + /// - For heartbeat events: Always contains a valid value (parsed or default 30s) + /// - For non-heartbeat events: nil + let heartbeatIntervalSeconds: TimeInterval? + + /// Raw event type string (preserved for logging unknown types) + let rawEventType: String? + + /// Creates a ServerEvent from raw SSE fields + /// Parsing is resilient - malformed data results in nil messages, not errors + init(id: String?, type: String?, data: String) { + self.id = id + self.rawEventType = type + self.eventType = EventType(rawValue: type ?? "") + self.data = data + self.messages = Self.parseMessages(eventType: eventType, data: data) + self.heartbeatIntervalSeconds = Self.parseHeartbeatInterval(eventType: eventType, data: data) + } + + /// Parses message data from SSE event into Message objects (same as polling does) + /// This method is resilient - it returns nil for any parsing failure without throwing + /// Note: No logging here since this is called from background thread; caller handles logging + private static func parseMessages(eventType: EventType, data: String) -> [Message]? { + // Only parse messages for message events + guard eventType == .messages else { return nil } + + // Empty data is valid (no messages) + guard !data.isEmpty else { return nil } + + // Convert to UTF8 data + guard let jsonData = data.data(using: .utf8) else { return nil } + + do { + // Parse JSON - expect array of dictionaries (same format as polling API) + let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: .allowFragments) + + guard let messageArray = jsonObject as? [[String: Any?]] else { return nil } + + // Convert dictionaries to UserQueueResponse, then to Message + // compactMap ensures invalid items are skipped without failing the whole batch + let userQueueResponses = messageArray.compactMap { UserQueueResponse(dictionary: $0) } + let messages = userQueueResponses.map { $0.toMessage() } + + return messages.isEmpty ? nil : messages + } catch { + return nil + } + } + + /// Parses heartbeat interval from heartbeat event data. + /// Corresponds to Android's `parseHeartbeatTimeout` function. + /// + /// Expected format: `{"heartbeat": 30}` where 30 is the interval in seconds. + /// + /// - Parameters: + /// - eventType: The event type (only parses for heartbeat events) + /// - data: JSON data from heartbeat event + /// - Returns: For heartbeat events: parsed interval or default (30s) if parsing fails/invalid. + /// For non-heartbeat events: nil + private static func parseHeartbeatInterval(eventType: EventType, data: String) -> TimeInterval? { + // Only parse for heartbeat events + guard eventType == .heartbeat else { return nil } + + let defaultTimeout = HeartbeatTimer.defaultHeartbeatTimeoutSeconds + + // Empty data means use default + guard !data.isEmpty, !data.trimmingCharacters(in: .whitespaces).isEmpty else { + return defaultTimeout + } + + // Convert to UTF8 data + guard let jsonData = data.data(using: .utf8) else { + return defaultTimeout + } + + do { + // Parse JSON - expect object with "heartbeat" key + let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: .allowFragments) + + guard let dictionary = jsonObject as? [String: Any], + let heartbeatValue = dictionary["heartbeat"] + else { + return defaultTimeout + } + + // Handle both Int and Double values, validating for positive values + // Non-positive values would cause immediate timer expiration, so use default + if let intValue = heartbeatValue as? Int, intValue > 0 { + return TimeInterval(intValue) + } else if let doubleValue = heartbeatValue as? Double, doubleValue > 0 { + return doubleValue + } + + return defaultTimeout + } catch { + return defaultTimeout + } + } +} + +// MARK: - SSE Error + +/// Represents different types of SSE errors with their classification for retry logic. +/// Corresponds to Android's `SseError` sealed class. +enum SseError: Equatable, Error { + /// Network-level error (connection failed, no internet, etc.) - retryable + case networkError(message: String, underlyingError: Error?) + + /// Heartbeat timeout - connection appears stale - retryable + case timeoutError + + /// Server returned an error response + /// - Parameters: + /// - message: Error description + /// - responseCode: HTTP status code (if available) + /// - shouldRetry: Whether this error should trigger retry logic + case serverError(message: String, responseCode: Int?, shouldRetry: Bool) + + /// Unknown/unexpected error - retryable by default + case unknownError(message: String, underlyingError: Error?) + + /// Configuration error (e.g., missing user token) - not retryable + case configurationError(message: String) + + /// Whether this error should trigger retry logic + /// Corresponds to Android's `shouldRetry` property + var shouldRetry: Bool { + switch self { + case .networkError: + return true + case .timeoutError: + return true + case .serverError(_, _, let shouldRetry): + return shouldRetry + case .unknownError: + return true + case .configurationError: + return false + } + } + + /// Human-readable error message for logging + var message: String { + switch self { + case .networkError(let message, _): + return "Network error: \(message)" + case .timeoutError: + return "Connection timeout" + case .serverError(let message, let code, _): + if let code = code { + return "Server error (HTTP \(code)): \(message)" + } + return "Server error: \(message)" + case .unknownError(let message, _): + return "Unknown error: \(message)" + case .configurationError(let message): + return "Configuration error: \(message)" + } + } + + /// Error type name for logging (matches Android error class names) + var errorType: String { + switch self { + case .networkError: + return "NetworkError" + case .timeoutError: + return "TimeoutError" + case .serverError(_, let code, _): + if let code = code { + return "ServerError(\(code))" + } + return "ServerError" + case .unknownError: + return "UnknownError" + case .configurationError: + return "ConfigurationError" + } + } + + static func == (lhs: SseError, rhs: SseError) -> Bool { + switch (lhs, rhs) { + case (.networkError(let lhsMsg, _), .networkError(let rhsMsg, _)): + return lhsMsg == rhsMsg + case (.timeoutError, .timeoutError): + return true + case (.serverError(let lhsMsg, let lhsCode, let lhsRetry), .serverError(let rhsMsg, let rhsCode, let rhsRetry)): + return lhsMsg == rhsMsg && lhsCode == rhsCode && lhsRetry == rhsRetry + case (.unknownError(let lhsMsg, _), .unknownError(let rhsMsg, _)): + return lhsMsg == rhsMsg + case (.configurationError(let lhsMsg), .configurationError(let rhsMsg)): + return lhsMsg == rhsMsg + default: + return false + } + } +} + +// MARK: - Error Classification + +/// Classifies errors into SSE error types for appropriate retry handling. +/// Corresponds to Android's `classifySseError` function. +func classifySseError(_ error: Error, responseCode: Int? = nil) -> SseError { + // Check for URL errors (network issues) + if let urlError = error as? URLError { + switch urlError.code { + case .notConnectedToInternet, + .networkConnectionLost, + .cannotFindHost, + .cannotConnectToHost, + .dnsLookupFailed: + return .networkError(message: urlError.localizedDescription, underlyingError: urlError) + case .timedOut: + return .timeoutError + default: + return .networkError(message: urlError.localizedDescription, underlyingError: urlError) + } + } + + // Check for HTTP response codes + if let code = responseCode { + let shouldRetry: Bool + switch code { + case 408, 429: // Request Timeout, Too Many Requests + shouldRetry = true + case 500 ... 599: // Server errors + shouldRetry = true + case 400 ... 499: // Client errors (except 408, 429) + shouldRetry = false + default: + shouldRetry = true + } + return .serverError(message: error.localizedDescription, responseCode: code, shouldRetry: shouldRetry) + } + + // Default to unknown error (retryable) + return .unknownError(message: error.localizedDescription, underlyingError: error) +} diff --git a/Sources/MessagingInApp/Gist/Network/SSE/SseRetryHelper.swift b/Sources/MessagingInApp/Gist/Network/SSE/SseRetryHelper.swift new file mode 100644 index 000000000..43131121c --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/SseRetryHelper.swift @@ -0,0 +1,169 @@ +import CioInternalCommon +import Foundation + +// sourcery: InjectRegisterShared = "SseRetryHelperProtocol" +/// Manages retry logic for SSE connections. +/// +/// This actor handles retry decision-making separately from the main connection manager. +/// It tracks retry attempts and emits decisions via an AsyncStream to the connection manager. +/// All delays and waiting happen inside this helper. +/// +/// Uses a connection generation ID to ensure retry operations only affect the correct +/// connection, preventing stale retries from triggering on new connections. +/// +/// Corresponds to Android's `SseRetryHelper` class. +actor SseRetryHelper: SseRetryHelperProtocol { + /// Maximum number of retry attempts before falling back to polling + static let maxRetryCount = 3 + + /// Delay between retry attempts (in seconds) - first retry is immediate + static let retryDelaySeconds: TimeInterval = 5.0 + + private let logger: Logger + private let sleeper: Sleeper + private var retryCount = 0 + private var retryTask: Task? + private var activeGeneration: UInt64 = 0 + + // AsyncStream continuation for emitting retry decisions + // Optional because stream is created fresh for each connection cycle + private var continuation: AsyncStream<(RetryDecision, UInt64)>.Continuation? + + init(logger: Logger, sleeper: Sleeper = RealSleeper()) { + self.logger = logger + self.sleeper = sleeper + logger.logWithModuleTag("[SseRetryHelper] Initialized", level: .debug) + } + + /// Creates a new retry decision stream for this connection cycle. + /// Any previous stream is finished (causes its iterator to exit cleanly). + /// - Returns: A new AsyncStream that will emit retry decisions + func createNewRetryStream() async -> AsyncStream<(RetryDecision, UInt64)> { + // Finish old stream to clean up any lingering iterators + continuation?.finish() + + let (stream, cont) = AsyncStreamBackport.makeStream(of: (RetryDecision, UInt64).self) + continuation = cont + + logger.logWithModuleTag("[SseRetryHelper] Created new retry stream", level: .debug) + return stream + } + + /// Sets the active connection generation. + /// Called when a new connection starts to reset retry state for the new generation. + /// + /// - Parameter generation: The new connection generation + func setActiveGeneration(_ generation: UInt64) { + logger.logWithModuleTag("[SseRetryHelper] Setting active generation to \(generation)", level: .debug) + activeGeneration = generation + retryTask?.cancel() + retryTask = nil + retryCount = 0 + } + + /// Schedules a retry for the given error and connection generation. + /// Only processes if the generation matches the active one. + /// + /// - Parameters: + /// - error: The error that caused the connection failure + /// - generation: The connection generation this retry is for + func scheduleRetry(error: SseError, generation: UInt64) { + guard generation == activeGeneration else { + logger.logWithModuleTag("[SseRetryHelper] Ignoring retry for stale generation \(generation) (active: \(activeGeneration))", level: .debug) + return + } + + if error.shouldRetry { + attemptRetry(generation: generation) + } else { + logger.logWithModuleTag("[SseRetryHelper] Non-retryable error - falling back to polling", level: .info) + emitRetryDecision(.retryNotPossible, generation: generation) + } + } + + /// Resets retry state for a specific generation. + /// Only resets if the generation matches the active one. + /// + /// - Parameter generation: The connection generation to reset + func resetRetryState(generation: UInt64) { + guard generation == activeGeneration else { + logger.logWithModuleTag("[SseRetryHelper] Skipping reset - generation mismatch (requested \(generation) vs active \(activeGeneration))", level: .debug) + return + } + + retryTask?.cancel() + retryTask = nil + retryCount = 0 + logger.logWithModuleTag("[SseRetryHelper] Retry state reset (generation \(generation))", level: .debug) + } + + // MARK: - Private Methods + + private func attemptRetry(generation: UInt64) { + if retryCount >= Self.maxRetryCount { + logger.logWithModuleTag("[SseRetryHelper] Max retries exceeded (\(retryCount)/\(Self.maxRetryCount)) - falling back to polling", level: .error) + emitRetryDecision(.maxRetriesReached, generation: generation) + return + } + + retryCount += 1 + let currentAttempt = retryCount + + // First retry - emit immediately (no delay) + if currentAttempt == 1 { + logger.logWithModuleTag("[SseRetryHelper] Scheduling immediate retry (attempt \(currentAttempt)/\(Self.maxRetryCount), generation \(generation))", level: .info) + emitRetryDecision(.retryNow(attemptCount: currentAttempt), generation: generation) + return + } + + // Subsequent retries - schedule with delay + logger.logWithModuleTag("[SseRetryHelper] Scheduling delayed retry in \(Self.retryDelaySeconds)s (attempt \(currentAttempt)/\(Self.maxRetryCount), generation \(generation))", level: .info) + + // Cancel any existing retry task + retryTask?.cancel() + + // Capture sleeper reference for the task + let sleeper = self.sleeper + + retryTask = Task { [weak self, currentAttempt, generation] in + do { + await self?.logger.logWithModuleTag("[SseRetryHelper] ⏳ Waiting \(Self.retryDelaySeconds)s before retry attempt \(currentAttempt)/\(Self.maxRetryCount)...", level: .info) + + // Use injected sleeper for delay (enables fast tests) + try await sleeper.sleep(seconds: Self.retryDelaySeconds) + + // Check if task was cancelled during sleep + guard !Task.isCancelled else { + await self?.logger.logWithModuleTag("[SseRetryHelper] Retry cancelled (generation \(generation))", level: .debug) + return + } + + // Emit with generation verification + await self?.emitIfStillActive(decision: .retryNow(attemptCount: currentAttempt), generation: generation) + } catch is CancellationError { + await self?.logger.logWithModuleTag("[SseRetryHelper] Retry cancelled (generation \(generation))", level: .debug) + } catch { + await self?.logger.logWithModuleTag("[SseRetryHelper] Unexpected error during retry delay: \(error.localizedDescription)", level: .error) + } + } + } + + private func emitIfStillActive(decision: RetryDecision, generation: UInt64) { + guard generation == activeGeneration else { + logger.logWithModuleTag("[SseRetryHelper] Skipping emit - generation mismatch (requested \(generation) vs active \(activeGeneration))", level: .debug) + return + } + + logger.logWithModuleTag("[SseRetryHelper] Delay completed, emitting retry decision (generation \(generation))", level: .info) + emitRetryDecision(decision, generation: generation) + } + + private func emitRetryDecision(_ decision: RetryDecision, generation: UInt64) { + guard let continuation = continuation else { + logger.logWithModuleTag("[SseRetryHelper] Warning: No active stream to emit decision", level: .error) + return + } + logger.logWithModuleTag("[SseRetryHelper] Emitting decision: \(decision) (generation \(generation))", level: .debug) + continuation.yield((decision, generation)) + } +} diff --git a/Sources/MessagingInApp/Gist/Network/SSE/SseRetryHelperProtocol.swift b/Sources/MessagingInApp/Gist/Network/SSE/SseRetryHelperProtocol.swift new file mode 100644 index 000000000..a75ef8c24 --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/SseRetryHelperProtocol.swift @@ -0,0 +1,30 @@ +import CioInternalCommon +import Foundation + +/// Protocol for SSE retry helper to enable testing with mocks. +/// Abstracts retry logic so `SseConnectionManager` can be tested +/// with controlled retry behavior. +protocol SseRetryHelperProtocol: AutoMockable { + /// Creates a new retry decision stream for this connection cycle. + /// Any previous stream is finished (causes its iterator to exit cleanly). + /// Call this at the start of each connection cycle to get a fresh stream. + /// - Returns: A new AsyncStream that will emit retry decisions + func createNewRetryStream() async -> AsyncStream<(RetryDecision, UInt64)> + + /// Sets the active connection generation. + /// Called when a new connection starts to reset retry state for the new generation. + /// - Parameter generation: The new connection generation + func setActiveGeneration(_ generation: UInt64) async + + /// Schedules a retry for the given error and connection generation. + /// Only processes if the generation matches the active one. + /// - Parameters: + /// - error: The error that caused the connection failure + /// - generation: The connection generation this retry is for + func scheduleRetry(error: SseError, generation: UInt64) async + + /// Resets retry state for a specific generation. + /// Only resets if the generation matches the active one. + /// - Parameter generation: The connection generation to reset + func resetRetryState(generation: UInt64) async +} diff --git a/Sources/MessagingInApp/Gist/Network/SSE/SseService.swift b/Sources/MessagingInApp/Gist/Network/SSE/SseService.swift new file mode 100644 index 000000000..9a084f711 --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/SseService.swift @@ -0,0 +1,246 @@ +import CioInternalCommon +import Foundation +import LDSwiftEventSource + +// sourcery: InjectRegisterShared = "SseServiceProtocol" +// sourcery: InjectSingleton +/// SSE service layer that wraps LDSwiftEventSource library and provides AsyncStream interface. +/// +/// Responsibilities: +/// - Build SSE URLs from connection parameters +/// - Wrap library callbacks as AsyncStream +/// - Track connection generations to prevent stale disconnects +/// - Provide clean async/await interface to SseConnectionManager +/// +/// Uses a connection generation ID to ensure `disconnect()` only affects the specific +/// connection it was meant for, preventing race conditions where a new connection +/// could be killed by cleanup from an old `stopConnection()` call. +actor SseService: SseServiceProtocol { + private let logger: Logger + private var eventSource: LDSwiftEventSource.EventSource? + /// The connection ID passed from the manager. Used to ensure disconnect() only affects the intended connection. + private var activeConnectionId: UInt64 = 0 + + init(logger: Logger) { + self.logger = logger + logger.logWithModuleTag("SseService initialized", level: .debug) + } + + // MARK: - Public API + + /// Starts SSE connection using the provided state and connection ID. + /// The connection ID is provided by the manager to ensure both layers share the same identifier. + /// - Parameters: + /// - state: The current InAppMessageState containing user and environment info + /// - connectionId: The connection ID from the manager, used to coordinate disconnect operations + /// - Returns: AsyncStream of SSE events + func connect(state: InAppMessageState, connectionId: UInt64) -> AsyncStream { + // Store the connection ID from the manager - this ensures both layers share the same ID + activeConnectionId = connectionId + + logger.logWithModuleTag("SseService: Initiating connection (connectionId \(connectionId))", level: .info) + + // Validate user identification + let identifier = state.userId ?? state.anonymousId + guard let identifier = identifier, !identifier.isEmpty else { + logger.logWithModuleTag("SseService: Cannot connect without user identifier", level: .error) + logger.logWithModuleTag("SseService: userId=\(state.userId ?? "nil"), anonymousId=\(state.anonymousId ?? "nil")", level: .debug) + // Emit configuration error so manager can handle appropriately + let stream = AsyncStream { continuation in + let error = SseError.configurationError(message: "Cannot connect without user identifier") + logger.logWithModuleTag("SseService: Emitting configuration error - not retryable", level: .error) + continuation.yield(.connectionFailed(error)) + continuation.finish() + } + return stream + } + + // Build SSE URL + guard let url = buildSseUrl(state: state, identifier: identifier) else { + logger.logWithModuleTag("SseService: Failed to build connection URL", level: .error) + let stream = AsyncStream { continuation in + let error = SseError.configurationError(message: "Failed to build SSE connection URL") + logger.logWithModuleTag("SseService: Emitting configuration error - not retryable", level: .error) + continuation.yield(.connectionFailed(error)) + continuation.finish() + } + return stream + } + + logger.logWithModuleTag("SseService: Connecting to \(url.absoluteString)", level: .info) + + // Create stream and continuation separately to avoid race conditions + // (Using backport for iOS 13+ compatibility; native makeStream() requires iOS 17+) + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + + // Create event handler with continuation + let handler = StreamEventHandler(continuation: continuation, logger: logger) + + // Configure EventSource + let headers = buildHeaders(state: state) + var config = LDSwiftEventSource.EventSource.Config(handler: handler, url: url) + config.headers = headers + config.idleTimeout = 300.0 // 5 minutes read timeout + + // Handle connection errors - emit to our stream before shutting down + // This is called INSTEAD of onError for connection-level failures + config.connectionErrorHandler = { [logger] error in + logger.logWithModuleTag("SseService: ✗ Connection error: \(error.localizedDescription)", level: .error) + + // Extract HTTP response code if available (LDSwiftEventSource provides UnsuccessfulResponseError for HTTP errors) + let responseCode = (error as? UnsuccessfulResponseError)?.responseCode + if let code = responseCode { + logger.logWithModuleTag("SseService: HTTP response code: \(code)", level: .debug) + } + + // Classify and emit error to stream + let sseError = classifySseError(error, responseCode: responseCode) + logger.logWithModuleTag("SseService: Classified as \(sseError.errorType), shouldRetry: \(sseError.shouldRetry)", level: .info) + continuation.yield(.connectionFailed(sseError)) + + // Shutdown - we'll handle retry logic ourselves + return .shutdown + } + + // Create EventSource and store reference synchronously (no race condition) + let newEventSource = LDSwiftEventSource.EventSource(config: config) + eventSource = newEventSource + + // Start connection + newEventSource.start() + logger.logWithModuleTag("SseService: EventSource started", level: .debug) + + // Handle stream termination (called when consumer stops iterating or Task is cancelled) + continuation.onTermination = { [weak newEventSource] _ in + newEventSource?.stop() + } + + return stream + } + + /// Stops the SSE connection only if the connection ID matches. + /// + /// This prevents race conditions where `stopConnection()` cleanup could kill + /// a newer connection that started while the old stop was awaiting. + /// + /// - Parameter connectionId: The connection ID to disconnect + func disconnect(connectionId: UInt64) { + guard activeConnectionId == connectionId else { + logger.logWithModuleTag("SseService: Skipping disconnect - connectionId mismatch (requested \(connectionId) vs current \(activeConnectionId))", level: .debug) + return + } + + logger.logWithModuleTag("SseService: Disconnecting (connectionId \(connectionId))", level: .info) + + eventSource?.stop() + eventSource = nil + + logger.logWithModuleTag("SseService: Disconnected", level: .debug) + } + + // MARK: - Private Helpers + + private func buildSseUrl(state: InAppMessageState, identifier: String) -> URL? { + // SSE API URL includes full path (like Android's getSseApiUrl()) + let sseUrlString = state.environment.networkSettings.sseAPI + guard var components = URLComponents(string: sseUrlString) else { + logger.logWithModuleTag("SseService: Invalid SSE URL: \(sseUrlString)", level: .error) + return nil + } + + // Add query parameters (matching Android's createSseRequest) + let userToken = Data(identifier.utf8).base64EncodedString() + let sessionId = SessionManager.shared.sessionId + components.queryItems = [ + URLQueryItem(name: "sessionId", value: sessionId), + URLQueryItem(name: "siteId", value: state.siteId), + URLQueryItem(name: "userToken", value: userToken) + ] + + logger.logWithModuleTag("SseService: Built URL with siteId=\(state.siteId), sessionId=\(sessionId), identifier=\(identifier)", level: .debug) + + return components.url + } + + /// Builds common headers for SSE connection (matching Android's addCommonHeaders with includeUserToken=false) + /// SSE uses userToken in URL query parameter, not in header + private func buildHeaders(state: InAppMessageState) -> [String: String] { + let sdkClient = DIGraphShared.shared.sdkClient + let isAnonymous = state.userId == nil + + return [ + HTTPHeader.siteId.rawValue: state.siteId, + HTTPHeader.cioDataCenter.rawValue: state.dataCenter, + HTTPHeader.cioClientPlatform.rawValue: sdkClient.source.lowercased() + "-apple", + HTTPHeader.cioClientVersion.rawValue: sdkClient.sdkVersion, + HTTPHeader.userAnonymous.rawValue: String(isAnonymous) + // Note: userToken is NOT included in headers for SSE - it's in the URL query params + ] + } +} + +// MARK: - LDSwiftEventSource Event Handler + +/// Bridges LDSwiftEventSource callbacks to our AsyncStream. +/// Maps library callbacks to SseEvent types matching Android's sealed interface. +private final class StreamEventHandler: EventHandler { + private let continuation: AsyncStream.Continuation + private let logger: Logger + + init(continuation: AsyncStream.Continuation, logger: Logger) { + self.continuation = continuation + self.logger = logger + } + + func onOpened() { + logger.logWithModuleTag("SseService: ✓ Connection opened", level: .info) + // Emit ConnectionOpenEvent (matches Android's ConnectionOpenEvent) + continuation.yield(.connectionOpen) + } + + func onClosed() { + logger.logWithModuleTag("SseService: Connection closed", level: .info) + // Emit ConnectionClosedEvent before finishing (matches Android's ConnectionClosedEvent) + continuation.yield(.connectionClosed) + continuation.finish() + } + + func onMessage(eventType: String, messageEvent: MessageEvent) { + logger.logWithModuleTag("SseService: → Server event - type: '\(eventType)'", level: .info) + logger.logWithModuleTag("SseService: data: \(messageEvent.data.prefix(100))\(messageEvent.data.count > 100 ? "..." : "")", level: .debug) + + // Emit ServerEvent (matches Android's ServerEvent) + let serverEvent = ServerEvent( + id: messageEvent.lastEventId, + type: eventType.isEmpty ? nil : eventType, + data: messageEvent.data + ) + continuation.yield(.serverEvent(serverEvent)) + } + + func onComment(comment: String) { + logger.logWithModuleTag("SseService: Comment received: \(comment.prefix(50))", level: .debug) + } + + func onError(error: Error) { + logger.logWithModuleTag("SseService: ✗ Error: \(error.localizedDescription)", level: .error) + + // Classify error for retry logic (matches Android's classifySseError) + // Extract HTTP response code if available + var responseCode: Int? + + // Check for UnsuccessfulResponseError (HTTP error responses from LDSwiftEventSource) + if let unsuccessfulResponse = error as? UnsuccessfulResponseError { + responseCode = unsuccessfulResponse.responseCode + logger.logWithModuleTag("SseService: HTTP response code: \(responseCode!)", level: .debug) + } else if let urlError = error as? URLError { + // URLError for network-level issues (no HTTP status code available) + logger.logWithModuleTag("SseService: URLError code: \(urlError.code.rawValue) (\(urlError.code))", level: .debug) + } + + let sseError = classifySseError(error, responseCode: responseCode) + logger.logWithModuleTag("SseService: Classified as \(sseError.errorType), shouldRetry: \(sseError.shouldRetry)", level: .info) + continuation.yield(.connectionFailed(sseError)) + continuation.finish() + } +} diff --git a/Sources/MessagingInApp/Gist/Network/SSE/SseServiceProtocol.swift b/Sources/MessagingInApp/Gist/Network/SSE/SseServiceProtocol.swift new file mode 100644 index 000000000..8c01b93d1 --- /dev/null +++ b/Sources/MessagingInApp/Gist/Network/SSE/SseServiceProtocol.swift @@ -0,0 +1,23 @@ +import CioInternalCommon +import Foundation + +/// Protocol for SSE service to enable testing with mocks. +/// Abstracts the SSE connection functionality so `SseConnectionManager` can be tested +/// without depending on the actual LDSwiftEventSource library. +/// +/// Methods are marked `async` to support actor conformance (`SseService` is an actor). +/// This ensures proper actor isolation and prevents data races. +protocol SseServiceProtocol: AutoMockable { + /// Starts SSE connection using the provided state and connection ID. + /// The connection ID is provided by the manager to ensure both layers share the same identifier. + /// - Parameters: + /// - state: The current InAppMessageState containing user and environment info + /// - connectionId: The connection ID from the manager, used to coordinate disconnect operations + /// - Returns: AsyncStream of SSE events + func connect(state: InAppMessageState, connectionId: UInt64) async -> AsyncStream + + /// Stops the SSE connection only if the connection ID matches. + /// This prevents race conditions where cleanup could kill a newer connection. + /// - Parameter connectionId: The connection ID to disconnect + func disconnect(connectionId: UInt64) async +} diff --git a/Sources/MessagingInApp/Gist/Utilities/ApplicationStateProvider.swift b/Sources/MessagingInApp/Gist/Utilities/ApplicationStateProvider.swift new file mode 100644 index 000000000..9000802ce --- /dev/null +++ b/Sources/MessagingInApp/Gist/Utilities/ApplicationStateProvider.swift @@ -0,0 +1,23 @@ +import CioInternalCommon +import Foundation +import UIKit + +/// Protocol for abstracting UIApplication state access. +/// Enables testing with controlled app states instead of relying on +/// the implicit simulator/device state during test execution. +protocol ApplicationStateProvider: AutoMockable { + /// Returns the current application state. + /// Must be called from the main thread. + @MainActor + var applicationState: UIApplication.State { get } +} + +// sourcery: InjectRegisterShared = "ApplicationStateProvider" +/// Production implementation that wraps UIApplication.shared. +/// Returns the actual application state from the system. +struct RealApplicationStateProvider: ApplicationStateProvider { + @MainActor + var applicationState: UIApplication.State { + UIApplication.shared.applicationState + } +} diff --git a/Sources/MessagingInApp/Gist/Utilities/SseLifecycleManager.swift b/Sources/MessagingInApp/Gist/Utilities/SseLifecycleManager.swift new file mode 100644 index 000000000..84b8d3128 --- /dev/null +++ b/Sources/MessagingInApp/Gist/Utilities/SseLifecycleManager.swift @@ -0,0 +1,301 @@ +import CioInternalCommon +import Foundation +import UIKit + +/// Manages the lifecycle-aware SSE connection for in-app messaging. +/// +/// This class encapsulates all logic for starting/stopping SSE connections based on: +/// - App foreground/background state +/// - SSE enabled flag from server +/// - User identification state (userId is set) +/// +/// SSE requires ALL three conditions to be met: +/// 1. App is foregrounded +/// 2. SSE flag is enabled (from X-CIO-Use-SSE header) +/// 3. User is identified (userId is set, not anonymous) +/// +/// Otherwise, the SDK falls back to polling. +/// +/// Corresponds to Android's `SseLifecycleManager` class. +protocol SseLifecycleManager: AutoMockable { + /// Starts the lifecycle manager. Must be called after initialization. + /// Sets up notification observers and subscribes to SSE flag and userId changes. + func start() async +} + +// sourcery: InjectRegisterShared = "SseLifecycleManager" +// sourcery: InjectSingleton +actor CioSseLifecycleManager: SseLifecycleManager { + private let logger: Logger + private let inAppMessageManager: InAppMessageManager + private let sseConnectionManager: SseConnectionManagerProtocol + private let applicationStateProvider: ApplicationStateProvider + + private var notificationObservers: [NSObjectProtocol] = [] + private var sseFlagSubscriber: InAppMessageStoreSubscriber? + private var userIdSubscriber: InAppMessageStoreSubscriber? + + private var isForegrounded: Bool = false + + init( + logger: Logger, + inAppMessageManager: InAppMessageManager, + sseConnectionManager: SseConnectionManagerProtocol, + applicationStateProvider: ApplicationStateProvider + ) { + self.logger = logger + self.inAppMessageManager = inAppMessageManager + self.sseConnectionManager = sseConnectionManager + self.applicationStateProvider = applicationStateProvider + } + + /// Sets up the lifecycle manager. Must be called after initialization. + /// This is separate from init because actors cannot call async methods in init. + /// + /// The order of operations is important to avoid race conditions: + /// 1. Register notification observers first to catch any state transitions + /// 2. Subscribe to SSE flag changes + /// 3. Check initial state last - any transitions during setup will be caught by observers + func start() async { + logger.logWithModuleTag("SseLifecycleManager: Starting lifecycle manager", level: .debug) + + // Register observers FIRST to ensure no state transitions are missed + await setupNotificationObservers() + subscribeToSseFlagChanges() + subscribeToUserIdChanges() + + // Check initial state LAST - if app went to background during setup, + // we'll either see it here or have already received the notification + await setupInitialState() + + logger.logWithModuleTag("SseLifecycleManager: Lifecycle manager started successfully", level: .info) + } + + deinit { + // Remove notification observers + let notificationCenter = NotificationCenter.default + for observer in notificationObservers { + notificationCenter.removeObserver(observer) + } + + // Note: Cannot unsubscribe from inAppMessageManager here as deinit cannot be async + // The subscriber uses weak reference so it will be cleaned up automatically + } + + // MARK: - Setup + + private func setupInitialState() async { + // Get current app state using injected provider for testability + let isForeground = await MainActor.run { + applicationStateProvider.applicationState != .background + } + + isForegrounded = isForeground + + // Get current state for logging and initial SSE state check + let state = await inAppMessageManager.state + + logger.logWithModuleTag( + "SseLifecycleManager: Initial state - isForegrounded: \(isForeground), sseEnabled: \(state.useSse), isUserIdentified: \(state.isUserIdentified), userId: \(state.userId ?? "nil")", + level: .info + ) + + // If already foregrounded at init time, check if we should start SSE + if isForeground { + logger.logWithModuleTag("SseLifecycleManager: App already foregrounded at init - checking SSE eligibility", level: .debug) + await startSseIfEligible(state: state) + } else { + logger.logWithModuleTag("SseLifecycleManager: App backgrounded at init - SSE will start when foregrounded", level: .debug) + } + } + + // MARK: - SSE Eligibility + + /// Starts SSE connection if all eligibility conditions are met. + /// Uses the provided state to check: sseEnabled && isUserIdentified + private func startSseIfEligible(state: InAppMessageState) async { + logger.logWithModuleTag( + "SseLifecycleManager: Checking SSE eligibility - sseEnabled: \(state.useSse), isUserIdentified: \(state.isUserIdentified)", + level: .debug + ) + + if state.shouldUseSse { + logger.logWithModuleTag("SseLifecycleManager: All conditions met - starting SSE connection", level: .info) + await sseConnectionManager.startConnection() + } else { + logger.logWithModuleTag( + "SseLifecycleManager: SSE conditions not met (sseEnabled: \(state.useSse), isUserIdentified: \(state.isUserIdentified)) - using polling", + level: .debug + ) + } + } + + private func setupNotificationObservers() async { + let notificationCenter = NotificationCenter.default + + // Observe when app enters foreground + let foregroundObserver = notificationCenter.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { + await self.handleForegrounded() + } + } + notificationObservers.append(foregroundObserver) + + // Observe when app enters background + let backgroundObserver = notificationCenter.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { + await self.handleBackgrounded() + } + } + notificationObservers.append(backgroundObserver) + + logger.logWithModuleTag("SseLifecycleManager: Notification observers registered", level: .debug) + } + + private func subscribeToSseFlagChanges() { + sseFlagSubscriber = { + let subscriber = InAppMessageStoreSubscriber { [weak self] state in + guard let self else { return } + Task { + await self.handleSseFlagChange(state: state) + } + } + // Subscribe to changes in `useSse` property of `InAppMessageState` + inAppMessageManager.subscribe(keyPath: \.useSse, subscriber: subscriber) + logger.logWithModuleTag("SseLifecycleManager: Subscribed to SSE flag changes", level: .debug) + return subscriber + }() + } + + private func subscribeToUserIdChanges() { + userIdSubscriber = { + let subscriber = InAppMessageStoreSubscriber { [weak self] state in + guard let self else { return } + Task { + await self.handleUserIdChange(state: state) + } + } + // Subscribe to changes in `userId` property of `InAppMessageState` + inAppMessageManager.subscribe(keyPath: \.userId, subscriber: subscriber) + logger.logWithModuleTag("SseLifecycleManager: Subscribed to userId changes", level: .debug) + return subscriber + }() + } + + // MARK: - Lifecycle Handlers + + private func handleForegrounded() async { + // Use compare-and-set pattern to avoid duplicate handling + guard !isForegrounded else { + logger.logWithModuleTag("SseLifecycleManager: Already foregrounded, skipping", level: .debug) + return + } + + isForegrounded = true + + // Get current state (like Android's getCurrentState()) + let state = await inAppMessageManager.state + + logger.logWithModuleTag( + "SseLifecycleManager: App foregrounded - checking SSE eligibility (sseEnabled: \(state.useSse), isUserIdentified: \(state.isUserIdentified))", + level: .info + ) + + // Check all 3 conditions: foregrounded + SSE enabled + user identified + await startSseIfEligible(state: state) + } + + private func handleBackgrounded() async { + // Use compare-and-set pattern to avoid duplicate handling + guard isForegrounded else { + logger.logWithModuleTag("SseLifecycleManager: Already backgrounded, skipping", level: .debug) + return + } + + isForegrounded = false + + // Always stop SSE connection when app backgrounds (matching Android behavior) + // stopConnection() is idempotent, safe to call even if not connected + logger.logWithModuleTag("SseLifecycleManager: App backgrounded - stopping SSE connection", level: .info) + await sseConnectionManager.stopConnection() + } + + private func handleSseFlagChange(state _: InAppMessageState) async { + // Always fetch latest state to handle out-of-order Task execution + // This prevents race conditions when state changes rapidly (e.g., SSE enabled → disabled) + let state = await inAppMessageManager.state + + logger.logWithModuleTag( + "SseLifecycleManager: SSE flag changed to \(state.useSse) (isUserIdentified: \(state.isUserIdentified), isForegrounded: \(isForegrounded))", + level: .info + ) + + // Only act on flag changes if app is foregrounded + guard isForegrounded else { + logger.logWithModuleTag( + "SseLifecycleManager: App backgrounded - deferring SSE action until foreground", + level: .debug + ) + return + } + + // Check if SSE should be used (matching Android's state.shouldUseSse check) + if state.shouldUseSse { + logger.logWithModuleTag("SseLifecycleManager: SSE enabled + user identified - starting SSE connection", level: .info) + await sseConnectionManager.startConnection() + } else if state.useSse, !state.isUserIdentified { + // SSE enabled but user is anonymous - don't start SSE + logger.logWithModuleTag("SseLifecycleManager: SSE enabled but user anonymous - SSE will not be used, polling continues", level: .info) + } else if !state.useSse { + // SSE disabled → Stop SSE connection (idempotent, safe to call even if not connected) + logger.logWithModuleTag("SseLifecycleManager: SSE disabled - stopping SSE connection, falling back to polling", level: .info) + await sseConnectionManager.stopConnection() + } + } + + private func handleUserIdChange(state _: InAppMessageState) async { + // Always fetch latest state to handle out-of-order Task execution + // This prevents race conditions when userId changes rapidly (e.g., login → logout) + let state = await inAppMessageManager.state + + logger.logWithModuleTag( + "SseLifecycleManager: User identification changed to \(state.isUserIdentified) (sseEnabled: \(state.useSse), isForegrounded: \(isForegrounded))", + level: .info + ) + + // Only act on identification changes if app is foregrounded + guard isForegrounded else { + logger.logWithModuleTag( + "SseLifecycleManager: App backgrounded - deferring user identification action until foreground", + level: .debug + ) + return + } + + // Check if SSE should be used (matching Android's state.shouldUseSse check) + if state.shouldUseSse { + // User became identified and SSE is enabled - start SSE connection + logger.logWithModuleTag("SseLifecycleManager: User identified + SSE enabled - starting SSE connection", level: .info) + await sseConnectionManager.startConnection() + } else if !state.isUserIdentified, state.useSse { + // User became anonymous and SSE flag is enabled - stop SSE, fall back to polling + logger.logWithModuleTag("SseLifecycleManager: User became anonymous - stopping SSE, falling back to polling", level: .info) + await sseConnectionManager.stopConnection() + } else { + logger.logWithModuleTag( + "SseLifecycleManager: No SSE action needed (shouldUseSse: \(state.shouldUseSse), sseEnabled: \(state.useSse), isUserIdentified: \(state.isUserIdentified))", + level: .debug + ) + } + } +} diff --git a/Sources/MessagingInApp/State/InAppMessageAction.swift b/Sources/MessagingInApp/State/InAppMessageAction.swift index 6f63611b0..591abd268 100644 --- a/Sources/MessagingInApp/State/InAppMessageAction.swift +++ b/Sources/MessagingInApp/State/InAppMessageAction.swift @@ -5,6 +5,7 @@ import Foundation enum InAppMessageAction: Equatable { case initialize(siteId: String, dataCenter: String, environment: GistEnvironment) case setPollingInterval(interval: Double) + case setSseEnabled(enabled: Bool) case setUserIdentifier(user: String) case setAnonymousIdentifier(anonymousId: String) case setPageRoute(route: String) @@ -34,6 +35,9 @@ enum InAppMessageAction: Equatable { case (.setPollingInterval(let lhsInterval), .setPollingInterval(let rhsInterval)): return lhsInterval == rhsInterval + case (.setSseEnabled(let lhsEnabled), .setSseEnabled(let rhsEnabled)): + return lhsEnabled == rhsEnabled + case (.setUserIdentifier(let lhsUser), .setUserIdentifier(let rhsUser)): return lhsUser == rhsUser diff --git a/Sources/MessagingInApp/State/InAppMessageMiddleware.swift b/Sources/MessagingInApp/State/InAppMessageMiddleware.swift index 1503f038e..78d957a91 100644 --- a/Sources/MessagingInApp/State/InAppMessageMiddleware.swift +++ b/Sources/MessagingInApp/State/InAppMessageMiddleware.swift @@ -118,7 +118,7 @@ private func logMessageView(logger: Logger, logManager: LogManager, state: InApp } func messageMetricsMiddleware(logger: Logger, logManager: LogManager, anonymousMessageManager: AnonymousMessageManager) -> InAppMessageMiddleware { - middleware { _, getState, next, action in + middleware { dispatch, getState, next, action in let state = getState() switch action { case .displayMessage(let message): @@ -151,6 +151,21 @@ func messageMetricsMiddleware(logger: Logger, logManager: LogManager, anonymousM logger.logWithModuleTag("Message dismissed, not logging view for message: \(message.describeForLogs), shouldLog: \(shouldLog), viaCloseAction: \(viaCloseAction)", level: .debug) } + // Process the DismissMessage action first so the reducer can update shownMessageQueueIds + // This ensures the dismissed message is properly marked as shown before processing the queue + next(action) + + // After the dismissal is processed, dispatch ProcessMessageQueue to show the next message + // This matches Android's behavior in gistLoggingMessageMiddleware where it dispatches + // ProcessMessageQueue(store.state.messagesInQueue) after DismissMessage when SSE is enabled. + // The dismissed message will be filtered out by messageQueueProcessorMiddleware + // since its queueId is now in shownMessageQueueIds + if state.shouldUseSse { + logger.logWithModuleTag("SSE enabled - Processing message queue after dismissal to show next message", level: .debug) + dispatch(.processMessageQueue(messages: Array(state.messagesInQueue))) + } + return + default: break } diff --git a/Sources/MessagingInApp/State/InAppMessageReducer.swift b/Sources/MessagingInApp/State/InAppMessageReducer.swift index 01cd8e5b5..dad26d863 100644 --- a/Sources/MessagingInApp/State/InAppMessageReducer.swift +++ b/Sources/MessagingInApp/State/InAppMessageReducer.swift @@ -32,6 +32,9 @@ private func reducer(action: InAppMessageAction, state: InAppMessageState) -> In case .setPollingInterval(let interval): return state.copy(pollInterval: interval) + case .setSseEnabled(let enabled): + return state.copy(useSse: enabled) + case .setUserIdentifier(let user): return state.copy(userId: user) diff --git a/Sources/MessagingInApp/State/InAppMessageState.swift b/Sources/MessagingInApp/State/InAppMessageState.swift index 53b54f35b..63cdb5723 100644 --- a/Sources/MessagingInApp/State/InAppMessageState.swift +++ b/Sources/MessagingInApp/State/InAppMessageState.swift @@ -12,6 +12,7 @@ struct InAppMessageState: Equatable, CustomStringConvertible { let userId: String? let anonymousId: String? let currentRoute: String? + let useSse: Bool let modalMessageState: ModalMessageState let embeddedMessagesState: EmbeddedMessagesState let messagesInQueue: Set @@ -25,6 +26,7 @@ struct InAppMessageState: Equatable, CustomStringConvertible { userId: String? = nil, anonymousId: String? = nil, currentRoute: String? = nil, + useSse: Bool = false, modalMessageState: ModalMessageState = .initial, embeddedMessagesState: EmbeddedMessagesState = EmbeddedMessagesState(), messagesInQueue: Set = [], @@ -37,6 +39,7 @@ struct InAppMessageState: Equatable, CustomStringConvertible { self.userId = userId self.anonymousId = anonymousId self.currentRoute = currentRoute + self.useSse = useSse self.modalMessageState = modalMessageState self.embeddedMessagesState = embeddedMessagesState self.messagesInQueue = messagesInQueue @@ -50,6 +53,7 @@ struct InAppMessageState: Equatable, CustomStringConvertible { userId: String? = nil, anonymousId: String? = nil, currentRoute: String? = nil, + useSse: Bool? = nil, modalMessageState: ModalMessageState? = nil, embeddedMessagesState: EmbeddedMessagesState? = nil, messagesInQueue: Set? = nil, @@ -63,6 +67,7 @@ struct InAppMessageState: Equatable, CustomStringConvertible { userId: userId ?? self.userId, anonymousId: anonymousId ?? self.anonymousId, currentRoute: currentRoute ?? self.currentRoute, + useSse: useSse ?? self.useSse, modalMessageState: modalMessageState ?? self.modalMessageState, embeddedMessagesState: embeddedMessagesState ?? self.embeddedMessagesState, messagesInQueue: messagesInQueue ?? self.messagesInQueue, @@ -78,6 +83,7 @@ struct InAppMessageState: Equatable, CustomStringConvertible { lhs.userId == rhs.userId && lhs.anonymousId == rhs.anonymousId && lhs.currentRoute == rhs.currentRoute && + lhs.useSse == rhs.useSse && lhs.modalMessageState == rhs.modalMessageState && lhs.messagesInQueue == rhs.messagesInQueue && lhs.shownMessageQueueIds == rhs.shownMessageQueueIds @@ -93,6 +99,7 @@ struct InAppMessageState: Equatable, CustomStringConvertible { userId: \(String(describing: userId)), anonymousId: \(String(describing: anonymousId)), currentRoute: \(String(describing: currentRoute)), + useSse: \(String(describing: useSse)), modalMessageState: \(modalMessageState), embeddedMessagesState: \(embeddedMessagesState), messagesInQueue: \(messagesInQueue.map(\.describeForLogs)), @@ -100,6 +107,22 @@ struct InAppMessageState: Equatable, CustomStringConvertible { ) """ } + + // MARK: - SSE Eligibility + + /// Returns true if user is identified (has a non-empty userId). + /// Anonymous users (only anonymousId) are not eligible for SSE. + var isUserIdentified: Bool { + guard let userId = userId else { return false } + return !userId.isEmpty + } + + /// Returns true if SSE should be used for real-time message delivery. + /// SSE requires both: SSE flag enabled AND user is identified. + /// Anonymous users always use polling even if SSE flag is enabled. + var shouldUseSse: Bool { + useSse && isUserIdentified + } } extension InAppMessageState { @@ -124,6 +147,7 @@ extension InAppMessageState { putIfDifferent(\.userId, as: "userId") putIfDifferent(\.anonymousId, as: "anonymousId") putIfDifferent(\.currentRoute, as: "currentRoute") + putIfDifferent(\.useSse, as: "useSse") putIfDifferent(\.modalMessageState, as: "currentMessageState") putIfDifferent(\.embeddedMessagesState, as: "embeddedMessagesState") putIfDifferent(\.messagesInQueue, as: "messagesInQueue") diff --git a/Sources/MessagingInApp/autogenerated/AutoDependencyInjection.generated.swift b/Sources/MessagingInApp/autogenerated/AutoDependencyInjection.generated.swift index a7aa28fd9..41f9db803 100644 --- a/Sources/MessagingInApp/autogenerated/AutoDependencyInjection.generated.swift +++ b/Sources/MessagingInApp/autogenerated/AutoDependencyInjection.generated.swift @@ -58,6 +58,9 @@ extension DIGraphShared { _ = anonymousMessageManager countDependenciesResolved += 1 + _ = sseLifecycleManager + countDependenciesResolved += 1 + _ = engineWebProvider countDependenciesResolved += 1 @@ -70,6 +73,9 @@ extension DIGraphShared { _ = gistQueueNetwork countDependenciesResolved += 1 + _ = heartbeatTimerProtocol + countDependenciesResolved += 1 + _ = inAppMessageManager countDependenciesResolved += 1 @@ -79,6 +85,21 @@ extension DIGraphShared { _ = queueManager countDependenciesResolved += 1 + _ = applicationStateProvider + countDependenciesResolved += 1 + + _ = sleeper + countDependenciesResolved += 1 + + _ = sseConnectionManagerProtocol + countDependenciesResolved += 1 + + _ = sseRetryHelperProtocol + countDependenciesResolved += 1 + + _ = sseServiceProtocol + countDependenciesResolved += 1 + return countDependenciesResolved } @@ -107,6 +128,30 @@ extension DIGraphShared { AnonymousMessageManagerImpl(keyValueStorage: sharedKeyValueStorage, dateUtil: dateUtil, logger: logger) } + // SseLifecycleManager (singleton) + var sseLifecycleManager: SseLifecycleManager { + getOverriddenInstance() ?? + sharedSseLifecycleManager + } + + var sharedSseLifecycleManager: SseLifecycleManager { + // Use a DispatchQueue to make singleton thread safe. You must create unique dispatchqueues instead of using 1 shared one or you will get a crash when trying + // to call DispatchQueue.sync{} while already inside another DispatchQueue.sync{} call. + DispatchQueue(label: "DIGraphShared_SseLifecycleManager_singleton_access").sync { + if let overridenDep: SseLifecycleManager = getOverriddenInstance() { + return overridenDep + } + let existingSingletonInstance = self.singletons[String(describing: SseLifecycleManager.self)] as? SseLifecycleManager + let instance = existingSingletonInstance ?? _get_sseLifecycleManager() + self.singletons[String(describing: SseLifecycleManager.self)] = instance + return instance + } + } + + private func _get_sseLifecycleManager() -> SseLifecycleManager { + CioSseLifecycleManager(logger: logger, inAppMessageManager: inAppMessageManager, sseConnectionManager: sseConnectionManagerProtocol, applicationStateProvider: applicationStateProvider) + } + // EngineWebProvider var engineWebProvider: EngineWebProvider { getOverriddenInstance() ?? @@ -138,7 +183,7 @@ extension DIGraphShared { } private func _get_gistProvider() -> GistProvider { - Gist(logger: logger, gistDelegate: gistDelegate, inAppMessageManager: inAppMessageManager, queueManager: queueManager, threadUtil: threadUtil) + Gist(logger: logger, gistDelegate: gistDelegate, inAppMessageManager: inAppMessageManager, queueManager: queueManager, threadUtil: threadUtil, sseLifecycleManager: sseLifecycleManager) } // GistDelegate (singleton) @@ -175,6 +220,16 @@ extension DIGraphShared { GistQueueNetworkImpl() } + // HeartbeatTimerProtocol + var heartbeatTimerProtocol: HeartbeatTimerProtocol { + getOverriddenInstance() ?? + newHeartbeatTimerProtocol + } + + private var newHeartbeatTimerProtocol: HeartbeatTimerProtocol { + HeartbeatTimer(logger: logger) + } + // InAppMessageManager (singleton) var inAppMessageManager: InAppMessageManager { getOverriddenInstance() ?? @@ -232,6 +287,84 @@ extension DIGraphShared { private func _get_queueManager() -> QueueManager { QueueManager(keyValueStore: sharedKeyValueStorage, gistQueueNetwork: gistQueueNetwork, inAppMessageManager: inAppMessageManager, anonymousMessageManager: anonymousMessageManager, logger: logger) } + + // ApplicationStateProvider + var applicationStateProvider: ApplicationStateProvider { + getOverriddenInstance() ?? + newApplicationStateProvider + } + + private var newApplicationStateProvider: ApplicationStateProvider { + RealApplicationStateProvider() + } + + // Sleeper + var sleeper: Sleeper { + getOverriddenInstance() ?? + newSleeper + } + + private var newSleeper: Sleeper { + RealSleeper() + } + + // SseConnectionManagerProtocol (singleton) + var sseConnectionManagerProtocol: SseConnectionManagerProtocol { + getOverriddenInstance() ?? + sharedSseConnectionManagerProtocol + } + + var sharedSseConnectionManagerProtocol: SseConnectionManagerProtocol { + // Use a DispatchQueue to make singleton thread safe. You must create unique dispatchqueues instead of using 1 shared one or you will get a crash when trying + // to call DispatchQueue.sync{} while already inside another DispatchQueue.sync{} call. + DispatchQueue(label: "DIGraphShared_SseConnectionManagerProtocol_singleton_access").sync { + if let overridenDep: SseConnectionManagerProtocol = getOverriddenInstance() { + return overridenDep + } + let existingSingletonInstance = self.singletons[String(describing: SseConnectionManagerProtocol.self)] as? SseConnectionManagerProtocol + let instance = existingSingletonInstance ?? _get_sseConnectionManagerProtocol() + self.singletons[String(describing: SseConnectionManagerProtocol.self)] = instance + return instance + } + } + + private func _get_sseConnectionManagerProtocol() -> SseConnectionManagerProtocol { + SseConnectionManager(logger: logger, inAppMessageManager: inAppMessageManager, sseService: sseServiceProtocol, retryHelper: sseRetryHelperProtocol, heartbeatTimer: heartbeatTimerProtocol) + } + + // SseRetryHelperProtocol + var sseRetryHelperProtocol: SseRetryHelperProtocol { + getOverriddenInstance() ?? + newSseRetryHelperProtocol + } + + private var newSseRetryHelperProtocol: SseRetryHelperProtocol { + SseRetryHelper(logger: logger, sleeper: sleeper) + } + + // SseServiceProtocol (singleton) + var sseServiceProtocol: SseServiceProtocol { + getOverriddenInstance() ?? + sharedSseServiceProtocol + } + + var sharedSseServiceProtocol: SseServiceProtocol { + // Use a DispatchQueue to make singleton thread safe. You must create unique dispatchqueues instead of using 1 shared one or you will get a crash when trying + // to call DispatchQueue.sync{} while already inside another DispatchQueue.sync{} call. + DispatchQueue(label: "DIGraphShared_SseServiceProtocol_singleton_access").sync { + if let overridenDep: SseServiceProtocol = getOverriddenInstance() { + return overridenDep + } + let existingSingletonInstance = self.singletons[String(describing: SseServiceProtocol.self)] as? SseServiceProtocol + let instance = existingSingletonInstance ?? _get_sseServiceProtocol() + self.singletons[String(describing: SseServiceProtocol.self)] = instance + return instance + } + } + + private func _get_sseServiceProtocol() -> SseServiceProtocol { + SseService(logger: logger) + } } // swiftlint:enable all diff --git a/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift b/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift index 2f03ae306..4449fcf16 100644 --- a/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift +++ b/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift @@ -224,6 +224,63 @@ class AnonymousMessageManagerMock: AnonymousMessageManager, Mock { } } +/** + Class to easily create a mocked version of the `ApplicationStateProvider` class. + This class is equipped with functions and properties ready for you to mock! + + Note: This file is automatically generated. This means the mocks should always be up-to-date and has a consistent API. + See the SDK documentation to learn the basics behind using the mock classes in the SDK. + */ +class ApplicationStateProviderMock: ApplicationStateProvider, Mock { + /// If *any* interactions done on mock. `true` if any method or property getter/setter called. + var mockCalled: Bool = false // + + init() { + Mocks.shared.add(mock: self) + } + + /** + When setter of the property called, the value given to setter is set here. + When the getter of the property called, the value set here will be returned. Your chance to mock the property. + */ + var underlyingApplicationState: UIApplication.State! + /// `true` if the getter or setter of property is called at least once. + var applicationStateCalled: Bool { + applicationStateGetCalled || applicationStateSetCalled + } + + /// `true` if the getter called on the property at least once. + var applicationStateGetCalled: Bool { + applicationStateGetCallsCount > 0 + } + + var applicationStateGetCallsCount = 0 + /// `true` if the setter called on the property at least once. + var applicationStateSetCalled: Bool { + applicationStateSetCallsCount > 0 + } + + var applicationStateSetCallsCount = 0 + /// The mocked property with a getter and setter. + var applicationState: UIApplication.State { + get { + mockCalled = true + applicationStateGetCallsCount += 1 + return underlyingApplicationState + } + set(value) { + mockCalled = true + applicationStateSetCallsCount += 1 + underlyingApplicationState = value + } + } + + public func resetMock() { + applicationStateGetCallsCount = 0 + applicationStateSetCallsCount = 0 + } +} + /** Class to easily create a mocked version of the `EngineWebInstance` class. This class is equipped with functions and properties ready for you to mock! @@ -618,6 +675,121 @@ class GistQueueNetworkMock: GistQueueNetwork, Mock { } } +/** + Class to easily create a mocked version of the `HeartbeatTimerProtocol` class. + This class is equipped with functions and properties ready for you to mock! + + Note: This file is automatically generated. This means the mocks should always be up-to-date and has a consistent API. + See the SDK documentation to learn the basics behind using the mock classes in the SDK. + */ +class HeartbeatTimerProtocolMock: HeartbeatTimerProtocol, Mock { + /// If *any* interactions done on mock. `true` if any method or property getter/setter called. + var mockCalled: Bool = false // + + init() { + Mocks.shared.add(mock: self) + } + + public func resetMock() { + setCallbackCallsCount = 0 + setCallbackReceivedArguments = nil + setCallbackReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + startTimerCallsCount = 0 + startTimerReceivedArguments = nil + startTimerReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + resetCallsCount = 0 + resetReceivedArguments = nil + resetReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + } + + // MARK: - setCallback + + /// Number of times the function was called. + @Atomic private(set) var setCallbackCallsCount = 0 + /// `true` if the function was ever called. + var setCallbackCalled: Bool { + setCallbackCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var setCallbackReceivedArguments: ((UInt64) async -> Void)? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var setCallbackReceivedInvocations: [(UInt64) async -> Void] = [] + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var setCallbackClosure: ((@escaping (UInt64) async -> Void) -> Void)? + + /// Mocked function for `setCallback(_ callback: @escaping (UInt64) async -> Void)`. Your opportunity to return a mocked value and check result of mock in test code. + func setCallback(_ callback: @escaping (UInt64) async -> Void) { + mockCalled = true + setCallbackCallsCount += 1 + setCallbackReceivedArguments = callback + setCallbackReceivedInvocations.append(callback) + setCallbackClosure?(callback) + } + + // MARK: - startTimer + + /// Number of times the function was called. + @Atomic private(set) var startTimerCallsCount = 0 + /// `true` if the function was ever called. + var startTimerCalled: Bool { + startTimerCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var startTimerReceivedArguments: (timeoutSeconds: TimeInterval, generation: UInt64)? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var startTimerReceivedInvocations: [(timeoutSeconds: TimeInterval, generation: UInt64)] = [] + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var startTimerClosure: ((TimeInterval, UInt64) -> Void)? + + /// Mocked function for `startTimer(timeoutSeconds: TimeInterval, generation: UInt64)`. Your opportunity to return a mocked value and check result of mock in test code. + func startTimer(timeoutSeconds: TimeInterval, generation: UInt64) { + mockCalled = true + startTimerCallsCount += 1 + startTimerReceivedArguments = (timeoutSeconds: timeoutSeconds, generation: generation) + startTimerReceivedInvocations.append((timeoutSeconds: timeoutSeconds, generation: generation)) + startTimerClosure?(timeoutSeconds, generation) + } + + // MARK: - reset + + /// Number of times the function was called. + @Atomic private(set) var resetCallsCount = 0 + /// `true` if the function was ever called. + var resetCalled: Bool { + resetCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var resetReceivedArguments: UInt64? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var resetReceivedInvocations: [UInt64] = [] + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var resetClosure: ((UInt64) -> Void)? + + /// Mocked function for `reset(generation: UInt64)`. Your opportunity to return a mocked value and check result of mock in test code. + func reset(generation: UInt64) { + mockCalled = true + resetCallsCount += 1 + resetReceivedArguments = generation + resetReceivedInvocations.append(generation) + resetClosure?(generation) + } +} + /** Class to easily create a mocked version of the `InAppEventListener` class. This class is equipped with functions and properties ready for you to mock! @@ -1263,4 +1435,399 @@ public class MessagingInAppInstanceMock: MessagingInAppInstance, Mock { } } +/** + Class to easily create a mocked version of the `Sleeper` class. + This class is equipped with functions and properties ready for you to mock! + + Note: This file is automatically generated. This means the mocks should always be up-to-date and has a consistent API. + See the SDK documentation to learn the basics behind using the mock classes in the SDK. + */ +class SleeperMock: Sleeper, Mock { + /// If *any* interactions done on mock. `true` if any method or property getter/setter called. + var mockCalled: Bool = false // + + init() { + Mocks.shared.add(mock: self) + } + + public func resetMock() { + sleepCallsCount = 0 + sleepReceivedArguments = nil + sleepReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + } + + // MARK: - sleep + + var sleepThrowableError: Error? + /// Number of times the function was called. + @Atomic private(set) var sleepCallsCount = 0 + /// `true` if the function was ever called. + var sleepCalled: Bool { + sleepCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var sleepReceivedArguments: TimeInterval? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var sleepReceivedInvocations: [TimeInterval] = [] + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var sleepClosure: ((TimeInterval) throws -> Void)? + + /// Mocked function for `sleep(seconds: TimeInterval)`. Your opportunity to return a mocked value and check result of mock in test code. + func sleep(seconds: TimeInterval) throws { + if let error = sleepThrowableError { + throw error + } + mockCalled = true + sleepCallsCount += 1 + sleepReceivedArguments = seconds + sleepReceivedInvocations.append(seconds) + try sleepClosure?(seconds) + } +} + +/** + Class to easily create a mocked version of the `SseConnectionManagerProtocol` class. + This class is equipped with functions and properties ready for you to mock! + + Note: This file is automatically generated. This means the mocks should always be up-to-date and has a consistent API. + See the SDK documentation to learn the basics behind using the mock classes in the SDK. + */ +class SseConnectionManagerProtocolMock: SseConnectionManagerProtocol, Mock { + /// If *any* interactions done on mock. `true` if any method or property getter/setter called. + var mockCalled: Bool = false // + + init() { + Mocks.shared.add(mock: self) + } + + public func resetMock() { + startConnectionCallsCount = 0 + + mockCalled = false // do last as resetting properties above can make this true + stopConnectionCallsCount = 0 + + mockCalled = false // do last as resetting properties above can make this true + } + + // MARK: - startConnection + + /// Number of times the function was called. + @Atomic private(set) var startConnectionCallsCount = 0 + /// `true` if the function was ever called. + var startConnectionCalled: Bool { + startConnectionCallsCount > 0 + } + + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var startConnectionClosure: (() -> Void)? + + /// Mocked function for `startConnection()`. Your opportunity to return a mocked value and check result of mock in test code. + func startConnection() { + mockCalled = true + startConnectionCallsCount += 1 + startConnectionClosure?() + } + + // MARK: - stopConnection + + /// Number of times the function was called. + @Atomic private(set) var stopConnectionCallsCount = 0 + /// `true` if the function was ever called. + var stopConnectionCalled: Bool { + stopConnectionCallsCount > 0 + } + + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var stopConnectionClosure: (() -> Void)? + + /// Mocked function for `stopConnection()`. Your opportunity to return a mocked value and check result of mock in test code. + func stopConnection() { + mockCalled = true + stopConnectionCallsCount += 1 + stopConnectionClosure?() + } +} + +/** + Class to easily create a mocked version of the `SseLifecycleManager` class. + This class is equipped with functions and properties ready for you to mock! + + Note: This file is automatically generated. This means the mocks should always be up-to-date and has a consistent API. + See the SDK documentation to learn the basics behind using the mock classes in the SDK. + */ +class SseLifecycleManagerMock: SseLifecycleManager, Mock { + /// If *any* interactions done on mock. `true` if any method or property getter/setter called. + var mockCalled: Bool = false // + + init() { + Mocks.shared.add(mock: self) + } + + public func resetMock() { + startCallsCount = 0 + + mockCalled = false // do last as resetting properties above can make this true + } + + // MARK: - start + + /// Number of times the function was called. + @Atomic private(set) var startCallsCount = 0 + /// `true` if the function was ever called. + var startCalled: Bool { + startCallsCount > 0 + } + + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var startClosure: (() -> Void)? + + /// Mocked function for `start()`. Your opportunity to return a mocked value and check result of mock in test code. + func start() { + mockCalled = true + startCallsCount += 1 + startClosure?() + } +} + +/** + Class to easily create a mocked version of the `SseRetryHelperProtocol` class. + This class is equipped with functions and properties ready for you to mock! + + Note: This file is automatically generated. This means the mocks should always be up-to-date and has a consistent API. + See the SDK documentation to learn the basics behind using the mock classes in the SDK. + */ +class SseRetryHelperProtocolMock: SseRetryHelperProtocol, Mock { + /// If *any* interactions done on mock. `true` if any method or property getter/setter called. + var mockCalled: Bool = false // + + init() { + Mocks.shared.add(mock: self) + } + + public func resetMock() { + createNewRetryStreamCallsCount = 0 + + mockCalled = false // do last as resetting properties above can make this true + setActiveGenerationCallsCount = 0 + setActiveGenerationReceivedArguments = nil + setActiveGenerationReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + scheduleRetryCallsCount = 0 + scheduleRetryReceivedArguments = nil + scheduleRetryReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + resetRetryStateCallsCount = 0 + resetRetryStateReceivedArguments = nil + resetRetryStateReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + } + + // MARK: - createNewRetryStream + + /// Number of times the function was called. + @Atomic private(set) var createNewRetryStreamCallsCount = 0 + /// `true` if the function was ever called. + var createNewRetryStreamCalled: Bool { + createNewRetryStreamCallsCount > 0 + } + + /// Value to return from the mocked function. + var createNewRetryStreamReturnValue: AsyncStream<(RetryDecision, UInt64)>! + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + The closure has first priority to return a value for the mocked function. If the closure returns `nil`, + then the mock will attempt to return the value for `createNewRetryStreamReturnValue` + */ + var createNewRetryStreamClosure: (() -> AsyncStream<(RetryDecision, UInt64)>)? + + /// Mocked function for `createNewRetryStream()`. Your opportunity to return a mocked value and check result of mock in test code. + func createNewRetryStream() -> AsyncStream<(RetryDecision, UInt64)> { + mockCalled = true + createNewRetryStreamCallsCount += 1 + return createNewRetryStreamClosure.map { $0() } ?? createNewRetryStreamReturnValue + } + + // MARK: - setActiveGeneration + + /// Number of times the function was called. + @Atomic private(set) var setActiveGenerationCallsCount = 0 + /// `true` if the function was ever called. + var setActiveGenerationCalled: Bool { + setActiveGenerationCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var setActiveGenerationReceivedArguments: UInt64? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var setActiveGenerationReceivedInvocations: [UInt64] = [] + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var setActiveGenerationClosure: ((UInt64) -> Void)? + + /// Mocked function for `setActiveGeneration(_ generation: UInt64)`. Your opportunity to return a mocked value and check result of mock in test code. + func setActiveGeneration(_ generation: UInt64) { + mockCalled = true + setActiveGenerationCallsCount += 1 + setActiveGenerationReceivedArguments = generation + setActiveGenerationReceivedInvocations.append(generation) + setActiveGenerationClosure?(generation) + } + + // MARK: - scheduleRetry + + /// Number of times the function was called. + @Atomic private(set) var scheduleRetryCallsCount = 0 + /// `true` if the function was ever called. + var scheduleRetryCalled: Bool { + scheduleRetryCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var scheduleRetryReceivedArguments: (error: SseError, generation: UInt64)? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var scheduleRetryReceivedInvocations: [(error: SseError, generation: UInt64)] = [] + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var scheduleRetryClosure: ((SseError, UInt64) -> Void)? + + /// Mocked function for `scheduleRetry(error: SseError, generation: UInt64)`. Your opportunity to return a mocked value and check result of mock in test code. + func scheduleRetry(error: SseError, generation: UInt64) { + mockCalled = true + scheduleRetryCallsCount += 1 + scheduleRetryReceivedArguments = (error: error, generation: generation) + scheduleRetryReceivedInvocations.append((error: error, generation: generation)) + scheduleRetryClosure?(error, generation) + } + + // MARK: - resetRetryState + + /// Number of times the function was called. + @Atomic private(set) var resetRetryStateCallsCount = 0 + /// `true` if the function was ever called. + var resetRetryStateCalled: Bool { + resetRetryStateCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var resetRetryStateReceivedArguments: UInt64? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var resetRetryStateReceivedInvocations: [UInt64] = [] + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var resetRetryStateClosure: ((UInt64) -> Void)? + + /// Mocked function for `resetRetryState(generation: UInt64)`. Your opportunity to return a mocked value and check result of mock in test code. + func resetRetryState(generation: UInt64) { + mockCalled = true + resetRetryStateCallsCount += 1 + resetRetryStateReceivedArguments = generation + resetRetryStateReceivedInvocations.append(generation) + resetRetryStateClosure?(generation) + } +} + +/** + Class to easily create a mocked version of the `SseServiceProtocol` class. + This class is equipped with functions and properties ready for you to mock! + + Note: This file is automatically generated. This means the mocks should always be up-to-date and has a consistent API. + See the SDK documentation to learn the basics behind using the mock classes in the SDK. + */ +class SseServiceProtocolMock: SseServiceProtocol, Mock { + /// If *any* interactions done on mock. `true` if any method or property getter/setter called. + var mockCalled: Bool = false // + + init() { + Mocks.shared.add(mock: self) + } + + public func resetMock() { + connectCallsCount = 0 + connectReceivedArguments = nil + connectReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + disconnectCallsCount = 0 + disconnectReceivedArguments = nil + disconnectReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + } + + // MARK: - connect + + /// Number of times the function was called. + @Atomic private(set) var connectCallsCount = 0 + /// `true` if the function was ever called. + var connectCalled: Bool { + connectCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var connectReceivedArguments: (state: InAppMessageState, connectionId: UInt64)? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var connectReceivedInvocations: [(state: InAppMessageState, connectionId: UInt64)] = [] + /// Value to return from the mocked function. + var connectReturnValue: AsyncStream! + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + The closure has first priority to return a value for the mocked function. If the closure returns `nil`, + then the mock will attempt to return the value for `connectReturnValue` + */ + var connectClosure: ((InAppMessageState, UInt64) -> AsyncStream)? + + /// Mocked function for `connect(state: InAppMessageState, connectionId: UInt64)`. Your opportunity to return a mocked value and check result of mock in test code. + func connect(state: InAppMessageState, connectionId: UInt64) -> AsyncStream { + mockCalled = true + connectCallsCount += 1 + connectReceivedArguments = (state: state, connectionId: connectionId) + connectReceivedInvocations.append((state: state, connectionId: connectionId)) + return connectClosure.map { $0(state, connectionId) } ?? connectReturnValue + } + + // MARK: - disconnect + + /// Number of times the function was called. + @Atomic private(set) var disconnectCallsCount = 0 + /// `true` if the function was ever called. + var disconnectCalled: Bool { + disconnectCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var disconnectReceivedArguments: UInt64? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var disconnectReceivedInvocations: [UInt64] = [] + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var disconnectClosure: ((UInt64) -> Void)? + + /// Mocked function for `disconnect(connectionId: UInt64)`. Your opportunity to return a mocked value and check result of mock in test code. + func disconnect(connectionId: UInt64) { + mockCalled = true + disconnectCallsCount += 1 + disconnectReceivedArguments = connectionId + disconnectReceivedInvocations.append(connectionId) + disconnectClosure?(connectionId) + } +} + // swiftlint:enable all diff --git a/Tests/MessagingInApp/Core/IntegrationTest.swift b/Tests/MessagingInApp/Core/IntegrationTest.swift index 9b80abc58..1303cfb05 100644 --- a/Tests/MessagingInApp/Core/IntegrationTest.swift +++ b/Tests/MessagingInApp/Core/IntegrationTest.swift @@ -27,8 +27,12 @@ open class IntegrationTest: UnitTest { } func setupHttpResponse(code: Int, body: Data) { + setupHttpResponse(code: code, body: body, headers: nil) + } + + func setupHttpResponse(code: Int, body: Data, headers: [String: String]?) { gistQueueNetworkMock.requestClosure = { _, _, completionHandler in - let response = HTTPURLResponse(url: URL(string: "https://test.com")!, statusCode: code, httpVersion: nil, headerFields: nil)! + let response = HTTPURLResponse(url: URL(string: "https://test.com")!, statusCode: code, httpVersion: nil, headerFields: headers)! completionHandler(.success((body, response))) } diff --git a/Tests/MessagingInApp/Gist/Network/SSE/ClassifySseErrorTest.swift b/Tests/MessagingInApp/Gist/Network/SSE/ClassifySseErrorTest.swift new file mode 100644 index 000000000..527c8a9af --- /dev/null +++ b/Tests/MessagingInApp/Gist/Network/SSE/ClassifySseErrorTest.swift @@ -0,0 +1,253 @@ +@testable import CioMessagingInApp +import Foundation +import XCTest + +/// Tests for the `classifySseError` function that classifies errors for retry logic. +class ClassifySseErrorTest: XCTestCase { + // MARK: - URLError Classification + + func test_classifySseError_givenNotConnectedToInternet_expectNetworkError() { + let urlError = URLError(.notConnectedToInternet) + + let result = classifySseError(urlError) + + XCTAssertEqual(result.errorType, "NetworkError") + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenNetworkConnectionLost_expectNetworkError() { + let urlError = URLError(.networkConnectionLost) + + let result = classifySseError(urlError) + + XCTAssertEqual(result.errorType, "NetworkError") + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenCannotFindHost_expectNetworkError() { + let urlError = URLError(.cannotFindHost) + + let result = classifySseError(urlError) + + XCTAssertEqual(result.errorType, "NetworkError") + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenCannotConnectToHost_expectNetworkError() { + let urlError = URLError(.cannotConnectToHost) + + let result = classifySseError(urlError) + + XCTAssertEqual(result.errorType, "NetworkError") + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenDnsLookupFailed_expectNetworkError() { + let urlError = URLError(.dnsLookupFailed) + + let result = classifySseError(urlError) + + XCTAssertEqual(result.errorType, "NetworkError") + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenTimedOut_expectTimeoutError() { + let urlError = URLError(.timedOut) + + let result = classifySseError(urlError) + + XCTAssertEqual(result.errorType, "TimeoutError") + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenOtherURLError_expectNetworkError() { + let urlError = URLError(.badURL) + + let result = classifySseError(urlError) + + XCTAssertEqual(result.errorType, "NetworkError") + XCTAssertTrue(result.shouldRetry) + } + + // MARK: - HTTP Response Code Classification + + func test_classifySseError_givenRequestTimeout408_expectServerErrorRetryable() { + let error = NSError(domain: "test", code: 408) + + let result = classifySseError(error, responseCode: 408) + + XCTAssertTrue(result.errorType.contains("ServerError")) + XCTAssertTrue(result.errorType.contains("408")) + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenTooManyRequests429_expectServerErrorRetryable() { + let error = NSError(domain: "test", code: 429) + + let result = classifySseError(error, responseCode: 429) + + XCTAssertTrue(result.errorType.contains("ServerError")) + XCTAssertTrue(result.errorType.contains("429")) + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenServerError500_expectServerErrorRetryable() { + let error = NSError(domain: "test", code: 500) + + let result = classifySseError(error, responseCode: 500) + + XCTAssertTrue(result.errorType.contains("ServerError")) + XCTAssertTrue(result.errorType.contains("500")) + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenServerError502_expectServerErrorRetryable() { + let error = NSError(domain: "test", code: 502) + + let result = classifySseError(error, responseCode: 502) + + XCTAssertTrue(result.errorType.contains("ServerError")) + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenServerError503_expectServerErrorRetryable() { + let error = NSError(domain: "test", code: 503) + + let result = classifySseError(error, responseCode: 503) + + XCTAssertTrue(result.errorType.contains("ServerError")) + XCTAssertTrue(result.shouldRetry) + } + + func test_classifySseError_givenClientError400_expectServerErrorNotRetryable() { + let error = NSError(domain: "test", code: 400) + + let result = classifySseError(error, responseCode: 400) + + XCTAssertTrue(result.errorType.contains("ServerError")) + XCTAssertTrue(result.errorType.contains("400")) + XCTAssertFalse(result.shouldRetry) + } + + func test_classifySseError_givenUnauthorized401_expectServerErrorNotRetryable() { + let error = NSError(domain: "test", code: 401) + + let result = classifySseError(error, responseCode: 401) + + XCTAssertTrue(result.errorType.contains("ServerError")) + XCTAssertFalse(result.shouldRetry) + } + + func test_classifySseError_givenForbidden403_expectServerErrorNotRetryable() { + let error = NSError(domain: "test", code: 403) + + let result = classifySseError(error, responseCode: 403) + + XCTAssertTrue(result.errorType.contains("ServerError")) + XCTAssertFalse(result.shouldRetry) + } + + func test_classifySseError_givenNotFound404_expectServerErrorNotRetryable() { + let error = NSError(domain: "test", code: 404) + + let result = classifySseError(error, responseCode: 404) + + XCTAssertTrue(result.errorType.contains("ServerError")) + XCTAssertFalse(result.shouldRetry) + } + + // MARK: - Unknown Error Classification + + func test_classifySseError_givenUnknownError_expectUnknownErrorRetryable() { + let error = NSError(domain: "custom", code: 999, userInfo: [NSLocalizedDescriptionKey: "Custom error"]) + + let result = classifySseError(error) + + XCTAssertEqual(result.errorType, "UnknownError") + XCTAssertTrue(result.shouldRetry) + } + + // MARK: - SseError Properties + + func test_sseError_networkError_message() { + let error = SseError.networkError(message: "Connection failed", underlyingError: nil) + + XCTAssertEqual(error.message, "Network error: Connection failed") + XCTAssertTrue(error.shouldRetry) + XCTAssertEqual(error.errorType, "NetworkError") + } + + func test_sseError_timeoutError_message() { + let error = SseError.timeoutError + + XCTAssertEqual(error.message, "Connection timeout") + XCTAssertTrue(error.shouldRetry) + XCTAssertEqual(error.errorType, "TimeoutError") + } + + func test_sseError_serverError_withCode_message() { + let error = SseError.serverError(message: "Internal Server Error", responseCode: 500, shouldRetry: true) + + XCTAssertEqual(error.message, "Server error (HTTP 500): Internal Server Error") + XCTAssertTrue(error.shouldRetry) + XCTAssertEqual(error.errorType, "ServerError(500)") + } + + func test_sseError_serverError_withoutCode_message() { + let error = SseError.serverError(message: "Unknown server error", responseCode: nil, shouldRetry: false) + + XCTAssertEqual(error.message, "Server error: Unknown server error") + XCTAssertFalse(error.shouldRetry) + XCTAssertEqual(error.errorType, "ServerError") + } + + func test_sseError_unknownError_message() { + let error = SseError.unknownError(message: "Something went wrong", underlyingError: nil) + + XCTAssertEqual(error.message, "Unknown error: Something went wrong") + XCTAssertTrue(error.shouldRetry) + XCTAssertEqual(error.errorType, "UnknownError") + } + + func test_sseError_configurationError_message() { + let error = SseError.configurationError(message: "Missing user token") + + XCTAssertEqual(error.message, "Configuration error: Missing user token") + XCTAssertFalse(error.shouldRetry) + XCTAssertEqual(error.errorType, "ConfigurationError") + } + + // MARK: - SseError Equatable + + func test_sseError_timeoutError_equatable() { + XCTAssertEqual(SseError.timeoutError, SseError.timeoutError) + } + + func test_sseError_serverError_equatable() { + let error1 = SseError.serverError(message: "Error", responseCode: 500, shouldRetry: true) + let error2 = SseError.serverError(message: "Error", responseCode: 500, shouldRetry: true) + let error3 = SseError.serverError(message: "Error", responseCode: 500, shouldRetry: false) + + XCTAssertEqual(error1, error2) + XCTAssertNotEqual(error1, error3) + } + + func test_sseError_configurationError_equatable() { + let error1 = SseError.configurationError(message: "Missing token") + let error2 = SseError.configurationError(message: "Missing token") + let error3 = SseError.configurationError(message: "Invalid config") + + XCTAssertEqual(error1, error2) + XCTAssertNotEqual(error1, error3) + } + + func test_sseError_differentTypes_notEqual() { + let networkError = SseError.networkError(message: "error", underlyingError: nil) + let timeoutError = SseError.timeoutError + let configError = SseError.configurationError(message: "error") + + XCTAssertNotEqual(networkError, timeoutError) + XCTAssertNotEqual(timeoutError, configError) + XCTAssertNotEqual(networkError, configError) + } +} diff --git a/Tests/MessagingInApp/Gist/Network/SSE/HeartbeatTimerTest.swift b/Tests/MessagingInApp/Gist/Network/SSE/HeartbeatTimerTest.swift new file mode 100644 index 000000000..a2808bca9 --- /dev/null +++ b/Tests/MessagingInApp/Gist/Network/SSE/HeartbeatTimerTest.swift @@ -0,0 +1,156 @@ +@testable import CioInternalCommon +@testable import CioMessagingInApp +import SharedTests +import XCTest + +class HeartbeatTimerTest: XCTestCase { + private var loggerMock: LoggerMock! + + override func setUp() { + super.setUp() + loggerMock = LoggerMock() + } + + // MARK: - Constants + + func test_constants_expectCorrectValues() { + XCTAssertEqual(HeartbeatTimer.defaultHeartbeatTimeoutSeconds, 30) + XCTAssertEqual(HeartbeatTimer.heartbeatBufferSeconds, 5) + XCTAssertEqual(HeartbeatTimer.initialTimeoutSeconds, 35) + } + + // MARK: - Timer Start/Reset + + func test_startTimer_expectTimerStarts() async { + var timeoutCalled = false + let timer = HeartbeatTimer(logger: loggerMock) + await timer.setCallback { _ in + timeoutCalled = true + } + + await timer.startTimer(timeoutSeconds: 100, generation: 1) + + // Timer should not fire immediately + XCTAssertFalse(timeoutCalled) + + // Cleanup + await timer.reset(generation: 1) + } + + func test_reset_givenTimerRunning_expectTimerCancelled() async throws { + var timeoutCalled = false + let timer = HeartbeatTimer(logger: loggerMock) + await timer.setCallback { _ in + timeoutCalled = true + } + + // Start timer with short timeout + await timer.startTimer(timeoutSeconds: 0.1, generation: 1) + + // Reset before it fires + await timer.reset(generation: 1) + + // Wait longer than the timeout + try await Task.sleep(nanoseconds: 200000000) // 0.2 seconds + + // Timeout should not have been called because we reset + XCTAssertFalse(timeoutCalled) + } + + func test_startTimer_givenMultipleStarts_expectPreviousTimerCancelled() async throws { + var timeoutCount = 0 + let timer = HeartbeatTimer(logger: loggerMock) + await timer.setCallback { _ in + timeoutCount += 1 + } + + // Start timer with very short timeout + await timer.startTimer(timeoutSeconds: 0.05, generation: 1) + + // Immediately start another timer with longer timeout + await timer.startTimer(timeoutSeconds: 0.5, generation: 1) + + // Wait for first timer's timeout to pass + try await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // First timer should have been cancelled, so timeout count should still be 0 + XCTAssertEqual(timeoutCount, 0) + + // Cleanup + await timer.reset(generation: 1) + } + + func test_timer_givenTimeoutExpires_expectCallbackInvoked() async throws { + let expectation = XCTestExpectation(description: "Timeout callback invoked") + var timeoutCalled = false + + let timer = HeartbeatTimer(logger: loggerMock) + await timer.setCallback { _ in + timeoutCalled = true + expectation.fulfill() + } + + // Start timer with very short timeout + await timer.startTimer(timeoutSeconds: 0.05, generation: 1) + + // Wait for timeout + await fulfillment(of: [expectation], timeout: 1.0) + + XCTAssertTrue(timeoutCalled) + } + + func test_reset_givenNoTimerRunning_expectNoError() async { + let timer = HeartbeatTimer(logger: loggerMock) + + // Should not throw or crash + await timer.reset(generation: 1) + await timer.reset(generation: 1) + } + + // MARK: - Generation Tests + + func test_reset_givenDifferentGeneration_expectTimerNotCancelled() async throws { + let expectation = XCTestExpectation(description: "Timeout callback invoked") + var timeoutCalled = false + + let timer = HeartbeatTimer(logger: loggerMock) + await timer.setCallback { _ in + timeoutCalled = true + expectation.fulfill() + } + + // Start timer with generation 2 + await timer.startTimer(timeoutSeconds: 0.05, generation: 2) + + // Try to reset with generation 1 (should be ignored) + await timer.reset(generation: 1) + + // Wait for timeout - should still fire because reset was for wrong generation + await fulfillment(of: [expectation], timeout: 1.0) + + XCTAssertTrue(timeoutCalled) + } + + func test_staleTimer_givenNewGeneration_expectCallbackIgnored() async throws { + var callbackGeneration: UInt64 = 0 + let expectation = XCTestExpectation(description: "Timeout callback invoked") + + let timer = HeartbeatTimer(logger: loggerMock) + await timer.setCallback { generation in + callbackGeneration = generation + expectation.fulfill() + } + + // Start timer with generation 1 + await timer.startTimer(timeoutSeconds: 0.05, generation: 1) + + // Immediately start new timer with generation 2 (simulating new connection) + await timer.startTimer(timeoutSeconds: 0.05, generation: 2) + + // Wait for timeout + await fulfillment(of: [expectation], timeout: 1.0) + + // Only generation 2 callback should have fired + XCTAssertEqual(callbackGeneration, 2) + } +} diff --git a/Tests/MessagingInApp/Gist/Network/SSE/RetryDecisionTest.swift b/Tests/MessagingInApp/Gist/Network/SSE/RetryDecisionTest.swift new file mode 100644 index 000000000..d24756a5b --- /dev/null +++ b/Tests/MessagingInApp/Gist/Network/SSE/RetryDecisionTest.swift @@ -0,0 +1,45 @@ +@testable import CioMessagingInApp +import XCTest + +/// Tests for the `RetryDecision` enum. +class RetryDecisionTest: XCTestCase { + // MARK: - Equatable + + func test_retryNow_sameAttemptCount_expectEqual() { + let decision1 = RetryDecision.retryNow(attemptCount: 1) + let decision2 = RetryDecision.retryNow(attemptCount: 1) + + XCTAssertEqual(decision1, decision2) + } + + func test_retryNow_differentAttemptCount_expectNotEqual() { + let decision1 = RetryDecision.retryNow(attemptCount: 1) + let decision2 = RetryDecision.retryNow(attemptCount: 2) + + XCTAssertNotEqual(decision1, decision2) + } + + func test_maxRetriesReached_expectEqual() { + let decision1 = RetryDecision.maxRetriesReached + let decision2 = RetryDecision.maxRetriesReached + + XCTAssertEqual(decision1, decision2) + } + + func test_retryNotPossible_expectEqual() { + let decision1 = RetryDecision.retryNotPossible + let decision2 = RetryDecision.retryNotPossible + + XCTAssertEqual(decision1, decision2) + } + + func test_differentDecisionTypes_expectNotEqual() { + let retryNow = RetryDecision.retryNow(attemptCount: 1) + let maxRetriesReached = RetryDecision.maxRetriesReached + let retryNotPossible = RetryDecision.retryNotPossible + + XCTAssertNotEqual(retryNow, maxRetriesReached) + XCTAssertNotEqual(retryNow, retryNotPossible) + XCTAssertNotEqual(maxRetriesReached, retryNotPossible) + } +} diff --git a/Tests/MessagingInApp/Gist/Network/SSE/ServerEventTest.swift b/Tests/MessagingInApp/Gist/Network/SSE/ServerEventTest.swift new file mode 100644 index 000000000..f38adee60 --- /dev/null +++ b/Tests/MessagingInApp/Gist/Network/SSE/ServerEventTest.swift @@ -0,0 +1,319 @@ +@testable import CioMessagingInApp +import XCTest + +class ServerEventTest: XCTestCase { + // MARK: - EventType Parsing + + func test_eventType_givenConnected_expectConnected() { + let eventType = ServerEvent.EventType(rawValue: "connected") + XCTAssertEqual(eventType, .connected) + } + + func test_eventType_givenHeartbeat_expectHeartbeat() { + let eventType = ServerEvent.EventType(rawValue: "heartbeat") + XCTAssertEqual(eventType, .heartbeat) + } + + func test_eventType_givenMessages_expectMessages() { + let eventType = ServerEvent.EventType(rawValue: "messages") + XCTAssertEqual(eventType, .messages) + } + + func test_eventType_givenTtlExceeded_expectTtlExceeded() { + let eventType = ServerEvent.EventType(rawValue: "ttl_exceeded") + XCTAssertEqual(eventType, .ttlExceeded) + } + + func test_eventType_givenEmptyString_expectMessages() { + // Per SSE spec, empty/nil event type defaults to "message" + let eventType = ServerEvent.EventType(rawValue: "") + XCTAssertEqual(eventType, .messages) + } + + func test_eventType_givenUnknownValue_expectUnknown() { + let eventType = ServerEvent.EventType(rawValue: "some_future_event") + XCTAssertEqual(eventType, .unknown) + } + + func test_eventType_givenRandomString_expectUnknown() { + let eventType = ServerEvent.EventType(rawValue: "xyz123") + XCTAssertEqual(eventType, .unknown) + } + + // MARK: - ServerEvent Initialization + + func test_serverEvent_givenConnectedType_expectConnectedEventType() { + let event = ServerEvent(id: nil, type: "connected", data: "{}") + XCTAssertEqual(event.eventType, .connected) + XCTAssertEqual(event.rawEventType, "connected") + XCTAssertNil(event.messages) + } + + func test_serverEvent_givenNilType_expectMessagesEventType() { + let event = ServerEvent(id: nil, type: nil, data: "[]") + XCTAssertEqual(event.eventType, .messages) + XCTAssertNil(event.rawEventType) + } + + func test_serverEvent_givenEventId_expectIdPreserved() { + let event = ServerEvent(id: "event-123", type: "heartbeat", data: "{}") + XCTAssertEqual(event.id, "event-123") + } + + // MARK: - Message Parsing - Valid Cases + + func test_parseMessages_givenValidJsonArray_expectMessages() { + // UserQueueResponse requires: queueId (String), priority (Int), messageId (String) + let jsonData = """ + [{"queueId": "q1", "priority": 1, "messageId": "m1"}, {"queueId": "q2", "priority": 2, "messageId": "m2"}] + """ + let event = ServerEvent(id: nil, type: "messages", data: jsonData) + + XCTAssertNotNil(event.messages) + XCTAssertEqual(event.messages?.count, 2) + } + + func test_parseMessages_givenSingleMessage_expectOneMessage() { + let jsonData = """ + [{"queueId": "q1", "priority": 1, "messageId": "m1"}] + """ + let event = ServerEvent(id: nil, type: "messages", data: jsonData) + + XCTAssertNotNil(event.messages) + XCTAssertEqual(event.messages?.count, 1) + } + + // MARK: - Message Parsing - Empty/Nil Cases + + func test_parseMessages_givenEmptyArray_expectNil() { + let event = ServerEvent(id: nil, type: "messages", data: "[]") + XCTAssertNil(event.messages) + } + + func test_parseMessages_givenEmptyString_expectNil() { + let event = ServerEvent(id: nil, type: "messages", data: "") + XCTAssertNil(event.messages) + } + + func test_parseMessages_givenWhitespaceOnly_expectNil() { + let event = ServerEvent(id: nil, type: "messages", data: " ") + XCTAssertNil(event.messages) + } + + // MARK: - Message Parsing - Non-Message Event Types + + func test_parseMessages_givenConnectedType_expectNilMessages() { + // Even with valid message JSON, non-message event types should not parse messages + let jsonData = """ + [{"queueId": "q1", "priority": 1, "messageId": "m1"}] + """ + let event = ServerEvent(id: nil, type: "connected", data: jsonData) + + // Should not parse messages for non-message event types + XCTAssertNil(event.messages) + } + + func test_parseMessages_givenHeartbeatType_expectNilMessages() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "{}") + XCTAssertNil(event.messages) + } + + // MARK: - Heartbeat Interval Parsing + + // Default timeout used when parsing fails (matches HeartbeatTimer.defaultHeartbeatTimeoutSeconds) + private let defaultHeartbeatTimeout: TimeInterval = 30 + + func test_parseHeartbeatInterval_givenValidHeartbeatData_expectIntervalParsed() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "{\"heartbeat\": 30}") + XCTAssertEqual(event.heartbeatIntervalSeconds, 30) + } + + func test_parseHeartbeatInterval_givenDoubleValue_expectIntervalParsed() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "{\"heartbeat\": 45.5}") + XCTAssertEqual(event.heartbeatIntervalSeconds, 45.5) + } + + // MARK: - Heartbeat Parsing - Returns Default for Invalid Data + + func test_parseHeartbeatInterval_givenEmptyData_expectDefault() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "") + XCTAssertEqual(event.heartbeatIntervalSeconds, defaultHeartbeatTimeout) + } + + func test_parseHeartbeatInterval_givenWhitespaceData_expectDefault() { + let event = ServerEvent(id: nil, type: "heartbeat", data: " ") + XCTAssertEqual(event.heartbeatIntervalSeconds, defaultHeartbeatTimeout) + } + + func test_parseHeartbeatInterval_givenEmptyJsonObject_expectDefault() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "{}") + XCTAssertEqual(event.heartbeatIntervalSeconds, defaultHeartbeatTimeout) + } + + func test_parseHeartbeatInterval_givenMissingHeartbeatKey_expectDefault() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "{\"interval\": 30}") + XCTAssertEqual(event.heartbeatIntervalSeconds, defaultHeartbeatTimeout) + } + + func test_parseHeartbeatInterval_givenInvalidJson_expectDefault() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "not json") + XCTAssertEqual(event.heartbeatIntervalSeconds, defaultHeartbeatTimeout) + } + + func test_parseHeartbeatInterval_givenMalformedJson_expectDefault() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "{invalid}") + XCTAssertEqual(event.heartbeatIntervalSeconds, defaultHeartbeatTimeout) + } + + func test_parseHeartbeatInterval_givenStringValue_expectDefault() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "{\"heartbeat\": \"30\"}") + XCTAssertEqual(event.heartbeatIntervalSeconds, defaultHeartbeatTimeout) + } + + func test_parseHeartbeatInterval_givenNullValue_expectDefault() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "{\"heartbeat\": null}") + XCTAssertEqual(event.heartbeatIntervalSeconds, defaultHeartbeatTimeout) + } + + func test_parseHeartbeatInterval_givenNegativeValue_expectDefault() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "{\"heartbeat\": -10}") + XCTAssertEqual(event.heartbeatIntervalSeconds, defaultHeartbeatTimeout) + } + + func test_parseHeartbeatInterval_givenZeroValue_expectDefault() { + let event = ServerEvent(id: nil, type: "heartbeat", data: "{\"heartbeat\": 0}") + XCTAssertEqual(event.heartbeatIntervalSeconds, defaultHeartbeatTimeout) + } + + // MARK: - Heartbeat Parsing - Returns Nil for Non-Heartbeat Types + + func test_parseHeartbeatInterval_givenNonHeartbeatType_expectNil() { + // Even with valid heartbeat JSON, non-heartbeat event types should not parse + let event = ServerEvent(id: nil, type: "connected", data: "{\"heartbeat\": 30}") + XCTAssertNil(event.heartbeatIntervalSeconds) + } + + func test_parseHeartbeatInterval_givenMessagesType_expectNil() { + let event = ServerEvent(id: nil, type: "messages", data: "{\"heartbeat\": 30}") + XCTAssertNil(event.heartbeatIntervalSeconds) + } + + func test_parseMessages_givenUnknownType_expectNilMessages() { + let jsonData = """ + [{"queueId": "q1", "priority": 1, "messageId": "m1"}] + """ + let event = ServerEvent(id: nil, type: "future_event", data: jsonData) + XCTAssertNil(event.messages) + } + + // MARK: - Message Parsing - Malformed JSON (Resilience Tests) + + func test_parseMessages_givenInvalidJson_expectNil() { + let event = ServerEvent(id: nil, type: "messages", data: "not json at all") + XCTAssertNil(event.messages) + } + + func test_parseMessages_givenMalformedJson_expectNil() { + let event = ServerEvent(id: nil, type: "messages", data: "{invalid json}") + XCTAssertNil(event.messages) + } + + func test_parseMessages_givenJsonObject_expectNil() { + // JSON object instead of array + let event = ServerEvent(id: nil, type: "messages", data: "{\"key\": \"value\"}") + XCTAssertNil(event.messages) + } + + func test_parseMessages_givenJsonString_expectNil() { + // JSON string instead of array + let event = ServerEvent(id: nil, type: "messages", data: "\"just a string\"") + XCTAssertNil(event.messages) + } + + func test_parseMessages_givenJsonNumber_expectNil() { + // JSON number instead of array + let event = ServerEvent(id: nil, type: "messages", data: "12345") + XCTAssertNil(event.messages) + } + + func test_parseMessages_givenArrayOfStrings_expectNil() { + // Array of strings instead of objects + let event = ServerEvent(id: nil, type: "messages", data: "[\"a\", \"b\", \"c\"]") + XCTAssertNil(event.messages) + } + + func test_parseMessages_givenArrayOfNumbers_expectNil() { + // Array of numbers instead of objects + let event = ServerEvent(id: nil, type: "messages", data: "[1, 2, 3]") + XCTAssertNil(event.messages) + } + + // MARK: - Message Parsing - Partial Validity + + func test_parseMessages_givenMixedValidAndInvalidItems_expectOnlyValidMessages() { + // Array with some valid and some invalid items + // Valid items have: queueId, priority, messageId + // Invalid item is missing required fields + let jsonData = """ + [{"queueId": "q1", "priority": 1, "messageId": "m1"}, {"invalid": "item"}, {"queueId": "q2", "priority": 2, "messageId": "m2"}] + """ + let event = ServerEvent(id: nil, type: "messages", data: jsonData) + + // Should parse valid items (2) and skip invalid one (1) + XCTAssertNotNil(event.messages) + XCTAssertEqual(event.messages?.count, 2) + } + + // MARK: - SseEvent Equatable + + func test_sseEvent_connectionOpen_equatable() { + let event1 = SseEvent.connectionOpen + let event2 = SseEvent.connectionOpen + XCTAssertEqual(event1, event2) + } + + func test_sseEvent_connectionClosed_equatable() { + let event1 = SseEvent.connectionClosed + let event2 = SseEvent.connectionClosed + XCTAssertEqual(event1, event2) + } + + func test_sseEvent_serverEvent_equatable() { + let serverEvent1 = ServerEvent(id: "1", type: "connected", data: "{}") + let serverEvent2 = ServerEvent(id: "1", type: "connected", data: "{}") + XCTAssertEqual(SseEvent.serverEvent(serverEvent1), SseEvent.serverEvent(serverEvent2)) + } + + func test_sseEvent_connectionFailed_equatable() { + let error1 = SseError.networkError(message: "error", underlyingError: nil) + let error2 = SseError.networkError(message: "error", underlyingError: nil) + XCTAssertEqual(SseEvent.connectionFailed(error1), SseEvent.connectionFailed(error2)) + } + + // MARK: - SseError + + func test_sseError_givenSameMessage_expectEqual() { + let error1 = SseError.networkError(message: "Connection failed", underlyingError: nil) + let error2 = SseError.networkError(message: "Connection failed", underlyingError: nil) + XCTAssertEqual(error1, error2) + } + + func test_sseError_givenDifferentMessage_expectNotEqual() { + let error1 = SseError.networkError(message: "Error 1", underlyingError: nil) + let error2 = SseError.networkError(message: "Error 2", underlyingError: nil) + XCTAssertNotEqual(error1, error2) + } + + func test_sseError_givenUnderlyingError_expectMessagePreserved() { + let underlyingError = NSError(domain: "test", code: 123) + let sseError = SseError.unknownError(message: "Wrapper error", underlyingError: underlyingError) + + XCTAssertTrue(sseError.message.contains("Wrapper error")) + // Verify underlyingError is captured in the enum case + if case .unknownError(_, let error) = sseError { + XCTAssertNotNil(error) + } else { + XCTFail("Expected unknownError case") + } + } +} diff --git a/Tests/MessagingInApp/Gist/Network/SSE/SseConnectionManagerTest.swift b/Tests/MessagingInApp/Gist/Network/SSE/SseConnectionManagerTest.swift new file mode 100644 index 000000000..3b531d334 --- /dev/null +++ b/Tests/MessagingInApp/Gist/Network/SSE/SseConnectionManagerTest.swift @@ -0,0 +1,390 @@ +@testable import CioInternalCommon +@testable import CioMessagingInApp +import SharedTests +import XCTest + +/// Tests for `SseConnectionManager` actor. +class SseConnectionManagerTest: XCTestCase { + private var loggerMock: LoggerMock! + private var inAppMessageManagerMock: InAppMessageManagerMock! + private var sseServiceMock: SseServiceProtocolMock! + private var retryHelperMock: SseRetryHelperProtocolMock! + private var heartbeatTimerMock: HeartbeatTimerProtocolMock! + + private var sut: SseConnectionManager! + + override func setUp() { + super.setUp() + loggerMock = LoggerMock() + inAppMessageManagerMock = InAppMessageManagerMock() + sseServiceMock = SseServiceProtocolMock() + retryHelperMock = SseRetryHelperProtocolMock() + heartbeatTimerMock = HeartbeatTimerProtocolMock() + + // Setup default mock state + inAppMessageManagerMock.underlyingState = InAppMessageState( + siteId: "test-site-id", + dataCenter: "us", + environment: .production, + userId: "test-user" + ) + + // Setup empty retry decision stream + let (stream, _) = AsyncStreamBackport.makeStream(of: (RetryDecision, UInt64).self) + retryHelperMock.createNewRetryStreamReturnValue = stream + + sut = SseConnectionManager( + logger: loggerMock, + inAppMessageManager: inAppMessageManagerMock, + sseService: sseServiceMock, + retryHelper: retryHelperMock, + heartbeatTimer: heartbeatTimerMock + ) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: - Start Connection Tests + + func test_startConnection_expectSseServiceConnectCalled() async { + // Setup: SSE service returns a stream that completes immediately + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + continuation.finish() + + // Action + await sut.startConnection() + + // Allow time for the async task to start + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert + XCTAssertTrue(sseServiceMock.connectCalled) + XCTAssertEqual(sseServiceMock.connectCallsCount, 1) + } + + func test_startConnection_givenAlreadyConnecting_expectNoSecondConnect() async { + // Setup: SSE service returns a stream that doesn't complete (simulating ongoing connection) + let (stream, _) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + // Action: Start connection twice + await sut.startConnection() + + // Allow the first connection to start + try? await Task.sleep(nanoseconds: 50000000) // 0.05 seconds + + await sut.startConnection() + + // Allow time for potential second connection + try? await Task.sleep(nanoseconds: 50000000) // 0.05 seconds + + // Assert: Second call should not trigger another connect + XCTAssertEqual(sseServiceMock.connectCallsCount, 1) + } + + func test_startConnection_expectHeartbeatCallbackSet() async { + // Setup + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + continuation.finish() + + // Action + await sut.startConnection() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert + XCTAssertTrue(heartbeatTimerMock.setCallbackCalled) + } + + // MARK: - Stop Connection Tests + + func test_stopConnection_expectSseServiceDisconnectCalled() async { + // Setup: Start a connection first + let (stream, _) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + await sut.startConnection() + try? await Task.sleep(nanoseconds: 50000000) // 0.05 seconds + + // Action + await sut.stopConnection() + + // Assert + XCTAssertTrue(sseServiceMock.disconnectCalled) + } + + func test_stopConnection_expectRetryStateReset() async { + // Setup + let (stream, _) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + await sut.startConnection() + try? await Task.sleep(nanoseconds: 50000000) // 0.05 seconds + + // Action + await sut.stopConnection() + + // Assert + XCTAssertTrue(retryHelperMock.resetRetryStateCalled) + } + + func test_stopConnection_expectHeartbeatTimerReset() async { + // Setup + let (stream, _) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + await sut.startConnection() + try? await Task.sleep(nanoseconds: 50000000) // 0.05 seconds + + // Action + await sut.stopConnection() + + // Assert + XCTAssertTrue(heartbeatTimerMock.resetCalled) + } + + // MARK: - Connection Events Tests + + func test_connectionOpen_expectHeartbeatTimerStarted() async { + // Setup: SSE service returns connectionOpen event + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + // Action + await sut.startConnection() + + // Send connectionOpen event + continuation.yield(.connectionOpen) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Clean up + continuation.finish() + + // Assert + XCTAssertTrue(heartbeatTimerMock.startTimerCalled) + } + + func test_connectionOpen_expectRetryStateReset() async { + // Setup + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + // Action + await sut.startConnection() + continuation.yield(.connectionOpen) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + continuation.finish() + + // Assert + XCTAssertTrue(retryHelperMock.resetRetryStateCalled) + } + + func test_connectionFailed_givenRetryableError_expectRetryScheduled() async { + // Setup + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + // Action + await sut.startConnection() + + let error = SseError.networkError(message: "Connection failed", underlyingError: nil) + continuation.yield(.connectionFailed(error)) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + continuation.finish() + + // Assert + XCTAssertTrue(retryHelperMock.scheduleRetryCalled) + XCTAssertEqual(retryHelperMock.scheduleRetryReceivedArguments?.error, error) + } + + func test_connectionFailed_expectHeartbeatTimerReset() async { + // Setup + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + // Action + await sut.startConnection() + continuation.yield(.connectionFailed(.networkError(message: "Error", underlyingError: nil))) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + continuation.finish() + + // Assert + XCTAssertTrue(heartbeatTimerMock.resetCalled) + } + + func test_connectionClosed_expectHeartbeatTimerReset() async { + // Setup + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + // Action + await sut.startConnection() + continuation.yield(.connectionClosed) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + continuation.finish() + + // Assert + XCTAssertTrue(heartbeatTimerMock.resetCalled) + } + + // MARK: - Server Event Tests + + func test_serverEvent_givenConnectedEvent_expectHeartbeatTimerStarted() async { + // Setup + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + // Action + await sut.startConnection() + + let serverEvent = ServerEvent(id: nil, type: "connected", data: "{}") + continuation.yield(.serverEvent(serverEvent)) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + continuation.finish() + + // Assert + XCTAssertTrue(heartbeatTimerMock.startTimerCalled) + } + + func test_serverEvent_givenHeartbeatEvent_expectHeartbeatTimerRestarted() async { + // Setup + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + // Action + await sut.startConnection() + + // First establish connection + continuation.yield(.connectionOpen) + try? await Task.sleep(nanoseconds: 50000000) // 0.05 seconds + + // Then receive heartbeat + let heartbeatEvent = ServerEvent(id: nil, type: "heartbeat", data: "{\"heartbeat\": 30}") + continuation.yield(.serverEvent(heartbeatEvent)) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + continuation.finish() + + // Assert: Timer should be started multiple times (once for connection, once for heartbeat) + XCTAssertGreaterThanOrEqual(heartbeatTimerMock.startTimerCallsCount, 2) + } + + func test_serverEvent_givenMessagesEvent_expectMessagesDispatched() async { + // Setup + let (stream, continuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = stream + + var dispatchedActions: [InAppMessageAction] = [] + inAppMessageManagerMock.dispatchClosure = { action, _ in + dispatchedActions.append(action) + return Task {} + } + + // Action + await sut.startConnection() + + // Create a valid messages event with proper JSON + let messagesJson = """ + [{"queueId": "q1", "priority": 1, "messageId": "m1"}] + """ + let messagesEvent = ServerEvent(id: nil, type: "messages", data: messagesJson) + continuation.yield(.serverEvent(messagesEvent)) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + continuation.finish() + + // Assert: Check if processMessageQueue action was dispatched + let processActions = dispatchedActions.filter { + if case .processMessageQueue = $0 { return true } + return false + } + XCTAssertEqual(processActions.count, 1) + } + + // MARK: - Retry Decision Tests + + func test_retryDecision_givenMaxRetriesReached_expectFallbackToPolling() async { + // Setup: Create a stream we can emit retry decisions on + let (retryStream, retryContinuation) = AsyncStreamBackport.makeStream(of: (RetryDecision, UInt64).self) + retryHelperMock.createNewRetryStreamReturnValue = retryStream + + let (sseStream, sseContinuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = sseStream + + var dispatchedActions: [InAppMessageAction] = [] + inAppMessageManagerMock.dispatchClosure = { action, _ in + dispatchedActions.append(action) + return Task {} + } + + // Create a fresh SUT with the mocked retry stream + sut = SseConnectionManager( + logger: loggerMock, + inAppMessageManager: inAppMessageManagerMock, + sseService: sseServiceMock, + retryHelper: retryHelperMock, + heartbeatTimer: heartbeatTimerMock + ) + + // Action + await sut.startConnection() + try? await Task.sleep(nanoseconds: 50000000) // 0.05 seconds + + // Emit maxRetriesReached decision (with generation 1) + retryContinuation.yield((.maxRetriesReached, 1)) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Clean up + sseContinuation.finish() + retryContinuation.finish() + + // Assert: Check that SSE was disabled (fallback to polling) + let sseDisabledActions = dispatchedActions.filter { + if case .setSseEnabled(enabled: false) = $0 { return true } + return false + } + XCTAssertEqual(sseDisabledActions.count, 1) + } + + func test_retryDecision_givenRetryNotPossible_expectFallbackToPolling() async { + // Setup + let (retryStream, retryContinuation) = AsyncStreamBackport.makeStream(of: (RetryDecision, UInt64).self) + retryHelperMock.createNewRetryStreamReturnValue = retryStream + + let (sseStream, sseContinuation) = AsyncStreamBackport.makeStream(of: SseEvent.self) + sseServiceMock.connectReturnValue = sseStream + + var dispatchedActions: [InAppMessageAction] = [] + inAppMessageManagerMock.dispatchClosure = { action, _ in + dispatchedActions.append(action) + return Task {} + } + + sut = SseConnectionManager( + logger: loggerMock, + inAppMessageManager: inAppMessageManagerMock, + sseService: sseServiceMock, + retryHelper: retryHelperMock, + heartbeatTimer: heartbeatTimerMock + ) + + // Action + await sut.startConnection() + try? await Task.sleep(nanoseconds: 50000000) // 0.05 seconds + + retryContinuation.yield((.retryNotPossible, 1)) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + sseContinuation.finish() + retryContinuation.finish() + + // Assert + let sseDisabledActions = dispatchedActions.filter { + if case .setSseEnabled(enabled: false) = $0 { return true } + return false + } + XCTAssertEqual(sseDisabledActions.count, 1) + } +} diff --git a/Tests/MessagingInApp/Gist/Network/SSE/SseConnectionStateTest.swift b/Tests/MessagingInApp/Gist/Network/SSE/SseConnectionStateTest.swift new file mode 100644 index 000000000..f8d6a06ef --- /dev/null +++ b/Tests/MessagingInApp/Gist/Network/SSE/SseConnectionStateTest.swift @@ -0,0 +1,43 @@ +@testable import CioMessagingInApp +import XCTest + +class SseConnectionStateTest: XCTestCase { + // MARK: - State Values + + func test_disconnected_description() { + let state = SseConnectionState.disconnected + XCTAssertEqual(state.description, "disconnected") + } + + func test_connecting_description() { + let state = SseConnectionState.connecting + XCTAssertEqual(state.description, "connecting") + } + + func test_connected_description() { + let state = SseConnectionState.connected + XCTAssertEqual(state.description, "connected") + } + + func test_disconnecting_description() { + let state = SseConnectionState.disconnecting + XCTAssertEqual(state.description, "disconnecting") + } + + // MARK: - Equatable + + func test_equatable_sameStates_expectEqual() { + XCTAssertEqual(SseConnectionState.disconnected, SseConnectionState.disconnected) + XCTAssertEqual(SseConnectionState.connecting, SseConnectionState.connecting) + XCTAssertEqual(SseConnectionState.connected, SseConnectionState.connected) + XCTAssertEqual(SseConnectionState.disconnecting, SseConnectionState.disconnecting) + } + + func test_equatable_differentStates_expectNotEqual() { + XCTAssertNotEqual(SseConnectionState.disconnected, SseConnectionState.connecting) + XCTAssertNotEqual(SseConnectionState.connecting, SseConnectionState.connected) + XCTAssertNotEqual(SseConnectionState.connected, SseConnectionState.disconnected) + XCTAssertNotEqual(SseConnectionState.disconnecting, SseConnectionState.connected) + XCTAssertNotEqual(SseConnectionState.disconnecting, SseConnectionState.disconnected) + } +} diff --git a/Tests/MessagingInApp/Gist/Network/SSE/SseRetryHelperTest.swift b/Tests/MessagingInApp/Gist/Network/SSE/SseRetryHelperTest.swift new file mode 100644 index 000000000..586a8af22 --- /dev/null +++ b/Tests/MessagingInApp/Gist/Network/SSE/SseRetryHelperTest.swift @@ -0,0 +1,270 @@ +@testable import CioInternalCommon +@testable import CioMessagingInApp +import SharedTests +import XCTest + +/// Mock sleeper that returns immediately for fast tests. +private class InstantSleeper: Sleeper { + var sleepCallCount = 0 + var lastSleepDuration: TimeInterval = 0 + + func sleep(seconds: TimeInterval) async throws { + sleepCallCount += 1 + lastSleepDuration = seconds + // Return immediately - no actual delay + } +} + +/// Tests for `SseRetryHelper` actor. +class SseRetryHelperTest: XCTestCase { + private var loggerMock: LoggerMock! + private var instantSleeper: InstantSleeper! + private let testGeneration: UInt64 = 1 + + override func setUp() { + super.setUp() + loggerMock = LoggerMock() + instantSleeper = InstantSleeper() + } + + // MARK: - Constants + + func test_constants_expectCorrectValues() { + XCTAssertEqual(SseRetryHelper.maxRetryCount, 3) + XCTAssertEqual(SseRetryHelper.retryDelaySeconds, 5.0) + } + + // MARK: - Retryable Errors + + func test_scheduleRetry_givenRetryableError_firstAttempt_expectImmediateRetry() async { + let helper = SseRetryHelper(logger: loggerMock, sleeper: instantSleeper) + let stream = await helper.createNewRetryStream() + var iterator = stream.makeAsyncIterator() + + // Set active generation before scheduling retry + await helper.setActiveGeneration(testGeneration) + + // Schedule retry with retryable error + let error = SseError.networkError(message: "Connection failed", underlyingError: nil) + await helper.scheduleRetry(error: error, generation: testGeneration) + + // Pull the first decision deterministically + let firstDecision = await iterator.next() + + // First retry should be immediate with attempt count 1 + XCTAssertNotNil(firstDecision) + XCTAssertEqual(firstDecision?.0, .retryNow(attemptCount: 1)) + + // First retry is immediate - no sleep should have been called + XCTAssertEqual(instantSleeper.sleepCallCount, 0) + } + + func test_scheduleRetry_givenRetryableError_secondAttempt_expectDelayedRetry() async throws { + let helper = SseRetryHelper(logger: loggerMock, sleeper: instantSleeper) + let stream = await helper.createNewRetryStream() + var iterator = stream.makeAsyncIterator() + + // Set active generation before scheduling retry + await helper.setActiveGeneration(testGeneration) + + let error = SseError.networkError(message: "Connection failed", underlyingError: nil) + + // First retry + await helper.scheduleRetry(error: error, generation: testGeneration) + + // Pull first decision + let firstDecision = await iterator.next() + XCTAssertEqual(firstDecision?.0, .retryNow(attemptCount: 1)) + + // Second retry - should use sleeper (but returns instantly in tests) + await helper.scheduleRetry(error: error, generation: testGeneration) + + // Pull second decision (instant because of mock sleeper) + let secondDecision = await iterator.next() + + XCTAssertNotNil(secondDecision) + XCTAssertEqual(secondDecision?.0, .retryNow(attemptCount: 2)) + + // Sleeper should have been called for the delayed retry + XCTAssertEqual(instantSleeper.sleepCallCount, 1) + XCTAssertEqual(instantSleeper.lastSleepDuration, SseRetryHelper.retryDelaySeconds) + } + + func test_scheduleRetry_givenMaxRetriesExceeded_expectMaxRetriesReachedDecision() async { + let helper = SseRetryHelper(logger: loggerMock, sleeper: instantSleeper) + let stream = await helper.createNewRetryStream() + var iterator = stream.makeAsyncIterator() + + // Set active generation before scheduling retry + await helper.setActiveGeneration(testGeneration) + + let error = SseError.networkError(message: "Connection failed", underlyingError: nil) + + // Exhaust all retries + // Attempt 1 (immediate) + await helper.scheduleRetry(error: error, generation: testGeneration) + // Attempt 2 (delayed - instant with mock) + await helper.scheduleRetry(error: error, generation: testGeneration) + // Attempt 3 (delayed - instant with mock) + await helper.scheduleRetry(error: error, generation: testGeneration) + // Attempt 4 - should exceed max + await helper.scheduleRetry(error: error, generation: testGeneration) + + // Pull decisions until we get maxRetriesReached + var decisions: [RetryDecision] = [] + while let decision = await iterator.next() { + decisions.append(decision.0) + if case .maxRetriesReached = decision.0 { + break + } + } + + // The last decision should be maxRetriesReached + XCTAssertEqual(decisions.last, .maxRetriesReached) + } + + // MARK: - Non-Retryable Errors + + func test_scheduleRetry_givenNonRetryableError_expectRetryNotPossible() async { + let helper = SseRetryHelper(logger: loggerMock, sleeper: instantSleeper) + let stream = await helper.createNewRetryStream() + var iterator = stream.makeAsyncIterator() + + // Set active generation before scheduling retry + await helper.setActiveGeneration(testGeneration) + + // Configuration error is not retryable + let error = SseError.configurationError(message: "Missing user token") + await helper.scheduleRetry(error: error, generation: testGeneration) + + // Pull the decision + let decision = await iterator.next() + + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.0, .retryNotPossible) + } + + func test_scheduleRetry_givenServerErrorNotRetryable_expectRetryNotPossible() async { + let helper = SseRetryHelper(logger: loggerMock, sleeper: instantSleeper) + let stream = await helper.createNewRetryStream() + var iterator = stream.makeAsyncIterator() + + // Set active generation before scheduling retry + await helper.setActiveGeneration(testGeneration) + + // 401 Unauthorized is not retryable + let error = SseError.serverError(message: "Unauthorized", responseCode: 401, shouldRetry: false) + await helper.scheduleRetry(error: error, generation: testGeneration) + + // Pull the decision + let decision = await iterator.next() + + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.0, .retryNotPossible) + } + + // MARK: - Reset State + + func test_resetRetryState_givenRetriesInProgress_expectCountReset() async { + let helper = SseRetryHelper(logger: loggerMock, sleeper: instantSleeper) + let stream = await helper.createNewRetryStream() + var iterator = stream.makeAsyncIterator() + + // Set active generation before scheduling retry + await helper.setActiveGeneration(testGeneration) + + let error = SseError.networkError(message: "Connection failed", underlyingError: nil) + + // Do 2 retries + await helper.scheduleRetry(error: error, generation: testGeneration) + await helper.scheduleRetry(error: error, generation: testGeneration) + + // Consume the first 2 decisions + _ = await iterator.next() + _ = await iterator.next() + + // Reset state + await helper.resetRetryState(generation: testGeneration) + + // Now retry again - should start from attempt 1 + await helper.scheduleRetry(error: error, generation: testGeneration) + + let decision = await iterator.next() + + // After reset, first retry should be attempt 1 again + XCTAssertEqual(decision?.0, .retryNow(attemptCount: 1)) + } + + // MARK: - Timeout Error + + func test_scheduleRetry_givenTimeoutError_expectRetryable() async { + let helper = SseRetryHelper(logger: loggerMock, sleeper: instantSleeper) + let stream = await helper.createNewRetryStream() + var iterator = stream.makeAsyncIterator() + + // Set active generation before scheduling retry + await helper.setActiveGeneration(testGeneration) + + let error = SseError.timeoutError + await helper.scheduleRetry(error: error, generation: testGeneration) + + let decision = await iterator.next() + + XCTAssertEqual(decision?.0, .retryNow(attemptCount: 1)) + } + + // MARK: - Generation Tests + + func test_scheduleRetry_givenStaleGeneration_expectIgnored() async { + let helper = SseRetryHelper(logger: loggerMock, sleeper: instantSleeper) + let stream = await helper.createNewRetryStream() + var iterator = stream.makeAsyncIterator() + + // Set active generation to 2 + await helper.setActiveGeneration(2) + + let error = SseError.networkError(message: "Connection failed", underlyingError: nil) + + // Try to schedule retry with stale generation 1 - should be ignored + await helper.scheduleRetry(error: error, generation: 1) + + // Schedule retry with correct generation 2 + await helper.scheduleRetry(error: error, generation: 2) + + // Only the second retry (generation 2) should be processed + let decision = await iterator.next() + + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.0, .retryNow(attemptCount: 1)) + XCTAssertEqual(decision?.1, 2) // Verify generation is 2 + } + + func test_resetRetryState_givenStaleGeneration_expectIgnored() async { + let helper = SseRetryHelper(logger: loggerMock, sleeper: instantSleeper) + let stream = await helper.createNewRetryStream() + var iterator = stream.makeAsyncIterator() + + // Set active generation + await helper.setActiveGeneration(testGeneration) + + let error = SseError.networkError(message: "Connection failed", underlyingError: nil) + + // Do a retry + await helper.scheduleRetry(error: error, generation: testGeneration) + + // Consume the first decision + _ = await iterator.next() + + // Try to reset with wrong generation - should be ignored + await helper.resetRetryState(generation: 999) + + // Do another retry - should be attempt 2 (not 1) because reset was ignored + await helper.scheduleRetry(error: error, generation: testGeneration) + + // Pull the decision (instant because of mock sleeper) + let decision = await iterator.next() + + // Should be attempt 2 because the stale reset was ignored + XCTAssertEqual(decision?.0, .retryNow(attemptCount: 2)) + } +} diff --git a/Tests/MessagingInApp/Gist/Utilities/SseLifecycleManagerTest.swift b/Tests/MessagingInApp/Gist/Utilities/SseLifecycleManagerTest.swift new file mode 100644 index 000000000..ab2161e6e --- /dev/null +++ b/Tests/MessagingInApp/Gist/Utilities/SseLifecycleManagerTest.swift @@ -0,0 +1,601 @@ +@testable import CioInternalCommon +@testable import CioMessagingInApp +import SharedTests +import UIKit +import XCTest + +/// Tests for `CioSseLifecycleManager` actor. +class SseLifecycleManagerTest: XCTestCase { + private var loggerMock: LoggerMock! + private var inAppMessageManagerMock: InAppMessageManagerMock! + private var sseConnectionManagerMock: SseConnectionManagerProtocolMock! + private var applicationStateProviderMock: ApplicationStateProviderMock! + + private var sut: CioSseLifecycleManager! + + override func setUp() { + super.setUp() + loggerMock = LoggerMock() + inAppMessageManagerMock = InAppMessageManagerMock() + sseConnectionManagerMock = SseConnectionManagerProtocolMock() + applicationStateProviderMock = ApplicationStateProviderMock() + + // Default to foreground state for most tests (explicit control) + applicationStateProviderMock.underlyingApplicationState = .active + + // Setup default return value for subscribe + inAppMessageManagerMock.subscribeReturnValue = Task {} + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: - Helper Methods + + private func createLifecycleManager() -> CioSseLifecycleManager { + CioSseLifecycleManager( + logger: loggerMock, + inAppMessageManager: inAppMessageManagerMock, + sseConnectionManager: sseConnectionManagerMock, + applicationStateProvider: applicationStateProviderMock + ) + } + + /// Sets up the default state for the InAppMessageManager mock + /// - Parameters: + /// - useSse: Whether SSE is enabled (from server header) + /// - userId: The userId (nil for anonymous users) + private func setupDefaultState(useSse: Bool = false, userId: String? = "test-user") { + inAppMessageManagerMock.underlyingState = InAppMessageState( + siteId: "test-site-id", + dataCenter: "us", + environment: .production, + userId: userId, + useSse: useSse + ) + } + + /// Triggers the SSE flag change subscriber with a new state. + /// The lifecycle manager registers subscribers in order: SSE flag (index 0), userId (index 1). + private func triggerSseFlagChange(useSse: Bool, userId: String? = "test-user") { + let newState = InAppMessageState( + siteId: "test-site-id", + dataCenter: "us", + environment: .production, + userId: userId, + useSse: useSse + ) + inAppMessageManagerMock.underlyingState = newState + // SSE flag subscriber is registered first (index 0) + guard !inAppMessageManagerMock.subscribeReceivedInvocations.isEmpty else { return } + inAppMessageManagerMock.subscribeReceivedInvocations[0].subscriber.newState(state: newState) + } + + /// Triggers the userId change subscriber with a new state. + /// The lifecycle manager registers subscribers in order: SSE flag (index 0), userId (index 1). + private func triggerUserIdChange(userId: String?, useSse: Bool = false) { + let newState = InAppMessageState( + siteId: "test-site-id", + dataCenter: "us", + environment: .production, + userId: userId, + useSse: useSse + ) + inAppMessageManagerMock.underlyingState = newState + // userId subscriber is registered second (index 1) + guard inAppMessageManagerMock.subscribeReceivedInvocations.count > 1 else { return } + inAppMessageManagerMock.subscribeReceivedInvocations[1].subscriber.newState(state: newState) + } + + // MARK: - Initial State Tests (App Foreground at Startup) + + func test_start_givenAppInForegroundAndSseEnabled_expectConnectionStarted() async { + // Setup: SSE enabled and app explicitly set to foreground + applicationStateProviderMock.underlyingApplicationState = .active + setupDefaultState(useSse: true) + sut = createLifecycleManager() + + // Action + await sut.start() + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Connection should be started + XCTAssertTrue(sseConnectionManagerMock.startConnectionCalled) + XCTAssertEqual(sseConnectionManagerMock.startConnectionCallsCount, 1) + } + + func test_start_givenAppInForegroundAndSseDisabled_expectNoConnection() async { + // Setup: SSE disabled, app in foreground + applicationStateProviderMock.underlyingApplicationState = .active + setupDefaultState(useSse: false) + sut = createLifecycleManager() + + // Action + await sut.start() + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Connection should NOT be started + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + } + + // MARK: - Initial State Tests (App Background at Startup) + + func test_start_givenAppInBackgroundAndSseEnabled_expectNoConnection() async { + // Setup: SSE enabled but app is in background (e.g., background fetch, push extension) + applicationStateProviderMock.underlyingApplicationState = .background + setupDefaultState(useSse: true) + sut = createLifecycleManager() + + // Action + await sut.start() + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Connection should NOT be started when app is backgrounded + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + } + + func test_start_givenAppInBackgroundAndSseDisabled_expectNoConnection() async { + // Setup: SSE disabled and app in background + applicationStateProviderMock.underlyingApplicationState = .background + setupDefaultState(useSse: false) + sut = createLifecycleManager() + + // Action + await sut.start() + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Connection should NOT be started + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + } + + func test_start_givenAppInInactiveStateAndSseEnabled_expectConnectionStarted() async { + // Setup: SSE enabled and app is inactive (transitioning, but not background) + // Inactive is treated as foreground since it's not .background + applicationStateProviderMock.underlyingApplicationState = .inactive + setupDefaultState(useSse: true) + sut = createLifecycleManager() + + // Action + await sut.start() + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Connection should be started (inactive is not background) + XCTAssertTrue(sseConnectionManagerMock.startConnectionCalled) + } + + // MARK: - Foreground Transition Tests + + func test_foregroundNotification_givenSseEnabled_expectConnectionStarted() async { + // Setup: Start with SSE enabled + setupDefaultState(useSse: true) + sut = createLifecycleManager() + await sut.start() + + // Reset mock to track only foreground-triggered calls + // First, the initial start() may have called startConnection, so we reset + sseConnectionManagerMock.resetMock() + + // Simulate app going to background first + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + sseConnectionManagerMock.resetMock() + + // Action: Simulate foreground notification + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert + XCTAssertTrue(sseConnectionManagerMock.startConnectionCalled) + XCTAssertEqual(sseConnectionManagerMock.startConnectionCallsCount, 1) + } + + func test_foregroundNotification_givenSseDisabled_expectNoConnection() async { + // Setup: SSE disabled + setupDefaultState(useSse: false) + sut = createLifecycleManager() + await sut.start() + + // Simulate app going to background first + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + sseConnectionManagerMock.resetMock() + + // Action: Simulate foreground notification + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: No connection should be started + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + } + + func test_foregroundNotification_givenAlreadyForegrounded_expectSkipped() async { + // Setup: SSE enabled + setupDefaultState(useSse: true) + sut = createLifecycleManager() + await sut.start() + + // Reset mock after initial start + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + sseConnectionManagerMock.resetMock() + + // Action: Send foreground notification while already foregrounded + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Should be skipped since already foregrounded + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + } + + // MARK: - Background Transition Tests + + func test_backgroundNotification_givenSseEnabled_expectConnectionStopped() async { + // Setup: SSE enabled + setupDefaultState(useSse: true) + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Action: Simulate background notification + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert + XCTAssertTrue(sseConnectionManagerMock.stopConnectionCalled) + XCTAssertEqual(sseConnectionManagerMock.stopConnectionCallsCount, 1) + } + + func test_backgroundNotification_givenSseDisabled_expectStopConnectionCalledAnyway() async { + // Setup: SSE disabled + setupDefaultState(useSse: false) + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Action: Simulate background notification + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: stopConnection is always called when backgrounding (matching Android behavior) + // stopConnection() is idempotent, safe to call even if not connected + XCTAssertTrue(sseConnectionManagerMock.stopConnectionCalled) + XCTAssertEqual(sseConnectionManagerMock.stopConnectionCallsCount, 1) + } + + func test_backgroundNotification_givenAlreadyBackgrounded_expectSkipped() async { + // Setup: SSE enabled + setupDefaultState(useSse: true) + sut = createLifecycleManager() + await sut.start() + + // First background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + sseConnectionManagerMock.resetMock() + + // Action: Send another background notification + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Should be skipped since already backgrounded + XCTAssertFalse(sseConnectionManagerMock.stopConnectionCalled) + } + + // MARK: - SSE Flag Change Tests + + func test_sseFlagChangedToTrue_givenForegrounded_expectConnectionStarted() async { + // Setup: SSE initially disabled + setupDefaultState(useSse: false) + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Verify no connection started initially + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + + // Action: Change SSE flag to true (simulating server enabling SSE) + triggerSseFlagChange(useSse: true) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert + XCTAssertTrue(sseConnectionManagerMock.startConnectionCalled) + } + + func test_sseFlagChangedToFalse_givenForegrounded_expectConnectionStopped() async { + // Setup: SSE initially enabled + setupDefaultState(useSse: true) + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Reset mock after initial connection + sseConnectionManagerMock.resetMock() + + // Action: Change SSE flag to false + triggerSseFlagChange(useSse: false) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert + XCTAssertTrue(sseConnectionManagerMock.stopConnectionCalled) + } + + func test_sseFlagChanged_givenBackgrounded_expectDeferredAction() async { + // Setup: SSE initially disabled + setupDefaultState(useSse: false) + sut = createLifecycleManager() + await sut.start() + + // Go to background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + sseConnectionManagerMock.resetMock() + + // Action: Change SSE flag to true while backgrounded + triggerSseFlagChange(useSse: true) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: No connection should be started while backgrounded + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + + // Action: Now foreground the app + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Connection should be started now + XCTAssertTrue(sseConnectionManagerMock.startConnectionCalled) + } + + // MARK: - Full Lifecycle Flow Tests + + func test_fullLifecycleFlow_foregroundBackgroundForeground() async { + // Setup: SSE enabled + setupDefaultState(useSse: true) + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Verify: Initial connection started + XCTAssertEqual(sseConnectionManagerMock.startConnectionCallsCount, 1) + XCTAssertEqual(sseConnectionManagerMock.stopConnectionCallsCount, 0) + + // Action: Go to background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Verify: Connection stopped + XCTAssertEqual(sseConnectionManagerMock.startConnectionCallsCount, 1) + XCTAssertEqual(sseConnectionManagerMock.stopConnectionCallsCount, 1) + + // Action: Return to foreground + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Verify: Connection started again + XCTAssertEqual(sseConnectionManagerMock.startConnectionCallsCount, 2) + XCTAssertEqual(sseConnectionManagerMock.stopConnectionCallsCount, 1) + } + + // MARK: - Anonymous User Tests (SSE requires identified user) + + func test_start_givenSseEnabledButAnonymousUser_expectNoConnection() async { + // Setup: SSE enabled but user is anonymous (no userId) + setupDefaultState(useSse: true, userId: nil) + sut = createLifecycleManager() + + // Action + await sut.start() + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Connection should NOT be started for anonymous users + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + } + + func test_foregroundNotification_givenSseEnabledButAnonymousUser_expectNoConnection() async { + // Setup: SSE enabled but user is anonymous + setupDefaultState(useSse: true, userId: nil) + sut = createLifecycleManager() + await sut.start() + + // Simulate going to background and back to foreground + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + sseConnectionManagerMock.resetMock() + + // Action: Simulate foreground notification + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: No connection should be started for anonymous users + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + } + + func test_sseFlagChangedToTrue_givenAnonymousUser_expectNoConnection() async { + // Setup: SSE initially disabled, user is anonymous + setupDefaultState(useSse: false, userId: nil) + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Verify no connection started initially + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + + // Action: Change SSE flag to true (simulating server enabling SSE) + triggerSseFlagChange(useSse: true, userId: nil) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Still no connection because user is anonymous + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + } + + // MARK: - User Identification Change Tests + + func test_userBecomesIdentified_givenSseEnabled_expectConnectionStarted() async { + // Setup: SSE enabled but user is initially anonymous + setupDefaultState(useSse: true, userId: nil) + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Verify no connection started initially + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + + // Action: User becomes identified + triggerUserIdChange(userId: "new-identified-user", useSse: true) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Connection should now be started + XCTAssertTrue(sseConnectionManagerMock.startConnectionCalled) + XCTAssertEqual(sseConnectionManagerMock.startConnectionCallsCount, 1) + } + + func test_userBecomesAnonymous_givenSseEnabledAndConnected_expectConnectionStopped() async { + // Setup: SSE enabled and user is identified + setupDefaultState(useSse: true, userId: "test-user") + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Verify connection started initially + XCTAssertTrue(sseConnectionManagerMock.startConnectionCalled) + XCTAssertEqual(sseConnectionManagerMock.startConnectionCallsCount, 1) + + // Action: User becomes anonymous (e.g., logout) + triggerUserIdChange(userId: nil, useSse: true) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Connection should be stopped + XCTAssertTrue(sseConnectionManagerMock.stopConnectionCalled) + XCTAssertEqual(sseConnectionManagerMock.stopConnectionCallsCount, 1) + } + + func test_userBecomesIdentified_givenSseDisabled_expectNoConnection() async { + // Setup: SSE disabled, user is anonymous + setupDefaultState(useSse: false, userId: nil) + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Action: User becomes identified but SSE is still disabled + triggerUserIdChange(userId: "new-identified-user", useSse: false) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: No connection because SSE flag is disabled + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + } + + func test_userBecomesIdentified_givenBackgrounded_expectDeferredConnection() async { + // Setup: SSE enabled, user is anonymous + setupDefaultState(useSse: true, userId: nil) + sut = createLifecycleManager() + await sut.start() + + // Go to background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + sseConnectionManagerMock.resetMock() + + // Action: User becomes identified while backgrounded + triggerUserIdChange(userId: "new-identified-user", useSse: true) + + // Allow time for async operations + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: No connection while backgrounded + XCTAssertFalse(sseConnectionManagerMock.startConnectionCalled) + + // Action: Return to foreground + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: Connection should now be started + XCTAssertTrue(sseConnectionManagerMock.startConnectionCalled) + } + + // MARK: - Combined State Change Tests + + func test_fullFlow_anonymousToIdentifiedToAnonymous() async { + // Setup: SSE enabled, user is anonymous + setupDefaultState(useSse: true, userId: nil) + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Verify: No initial connection (anonymous user) + XCTAssertEqual(sseConnectionManagerMock.startConnectionCallsCount, 0) + XCTAssertEqual(sseConnectionManagerMock.stopConnectionCallsCount, 0) + + // Action: User logs in (becomes identified) + triggerUserIdChange(userId: "logged-in-user", useSse: true) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Verify: Connection started + XCTAssertEqual(sseConnectionManagerMock.startConnectionCallsCount, 1) + XCTAssertEqual(sseConnectionManagerMock.stopConnectionCallsCount, 0) + + // Action: User logs out (becomes anonymous) + triggerUserIdChange(userId: nil, useSse: true) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Verify: Connection stopped + XCTAssertEqual(sseConnectionManagerMock.startConnectionCallsCount, 1) + XCTAssertEqual(sseConnectionManagerMock.stopConnectionCallsCount, 1) + } + + func test_backgroundNotification_givenAnonymousUser_expectStopConnectionCalledAnyway() async { + // Setup: SSE enabled but user is anonymous + setupDefaultState(useSse: true, userId: nil) + sut = createLifecycleManager() + await sut.start() + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Action: Go to background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100000000) // 0.1 seconds + + // Assert: stopConnection is always called when backgrounding (matching Android behavior) + // stopConnection() is idempotent, safe to call even if SSE was never started + XCTAssertTrue(sseConnectionManagerMock.stopConnectionCalled) + XCTAssertEqual(sseConnectionManagerMock.stopConnectionCallsCount, 1) + } +} diff --git a/Tests/MessagingInApp/State/InAppMessageStateTests.swift b/Tests/MessagingInApp/State/InAppMessageStateTests.swift index 8768eca42..9edbf0179 100644 --- a/Tests/MessagingInApp/State/InAppMessageStateTests.swift +++ b/Tests/MessagingInApp/State/InAppMessageStateTests.swift @@ -50,7 +50,8 @@ class InAppMessageStateTests: IntegrationTest { gistDelegate: diGraphShared.gistDelegate, inAppMessageManager: inAppMessageManager, queueManager: queueManager, - threadUtil: diGraphShared.threadUtil + threadUtil: diGraphShared.threadUtil, + sseLifecycleManager: diGraphShared.sseLifecycleManager ) } @@ -78,6 +79,7 @@ class InAppMessageStateTests: IntegrationTest { XCTAssertEqual(state.pollInterval, 600) XCTAssertNil(state.userId) XCTAssertNil(state.currentRoute) + XCTAssertEqual(state.useSse, false) XCTAssertEqual(state.modalMessageState, .initial) XCTAssertTrue(state.messagesInQueue.isEmpty) XCTAssertTrue(state.shownMessageQueueIds.isEmpty) @@ -585,6 +587,487 @@ class InAppMessageStateTests: IntegrationTest { XCTAssertTrue(state.messagesInQueue.isEmpty) } + + // MARK: - SSE Flag Tests + + func test_setSseEnabled_givenTrue_expectSseFlagSetToTrue() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + + let state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, true) + } + + func test_setSseEnabled_givenFalse_expectSseFlagSetToFalse() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: false)) + + let state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, false) + } + + func test_setSseEnabled_givenMultipleChanges_expectStateUpdated() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + var state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, true) + + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: false)) + state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, false) + + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, true) + } + + func test_resetState_expectSseFlagCleared() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + var state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, true) + + await inAppMessageManager.dispatchAsync(action: .resetState) + state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, false) + } + + // MARK: - SSE Header Detection Tests + + func test_fetch_givenSseHeaderTrue_expectSseFlagSetToTrue() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + var state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, false) + + let headers = [ + "x-gist-queue-polling-interval": "600", + "x-cio-use-sse": "true" + ] + setupHttpResponse(code: 200, body: "[]".data, headers: headers) + + queueManager.fetchUserQueue(state: state) { _ in } + + state = await inAppMessageManager.waitForState { state in + state.useSse == true + } + XCTAssertEqual(state.useSse, true) + } + + func test_fetch_givenSseHeaderFalse_expectSseFlagSetToFalse() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + var state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, false) + + let headers = [ + "x-gist-queue-polling-interval": "600", + "x-cio-use-sse": "false" + ] + setupHttpResponse(code: 200, body: "[]".data, headers: headers) + + queueManager.fetchUserQueue(state: state) { _ in } + + // Since it's already false, verify it remains false + state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, false) + } + + func test_fetch_givenNoSseHeader_expectSseFlagUnchanged() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + var state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, false) + + let headers = [ + "x-gist-queue-polling-interval": "600" + ] + setupHttpResponse(code: 200, body: "[]".data, headers: headers) + + queueManager.fetchUserQueue(state: state) { _ in } + + // Verify SSE flag remains unchanged + state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, false) + } + + func test_fetch_givenInvalidSseHeaderValue_expectSseFlagSetToFalse() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + var state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, false) + + let headers = [ + "x-gist-queue-polling-interval": "600", + "x-cio-use-sse": "invalid" + ] + setupHttpResponse(code: 200, body: "[]".data, headers: headers) + + queueManager.fetchUserQueue(state: state) { _ in } + + // Since it's already false and "invalid" converts to false, verify it remains false + state = await inAppMessageManager.state + XCTAssertEqual(state.useSse, false) + } + + func test_fetch_givenSseHeaderChangesFromTrueToFalse_expectSseFlagUpdated() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + var state = await inAppMessageManager.state + + // First fetch with SSE enabled + var headers = [ + "x-gist-queue-polling-interval": "600", + "x-cio-use-sse": "true" + ] + setupHttpResponse(code: 200, body: "[]".data, headers: headers) + queueManager.fetchUserQueue(state: state) { _ in } + + state = await inAppMessageManager.waitForState { state in + state.useSse == true + } + XCTAssertEqual(state.useSse, true) + + // Second fetch with SSE disabled + headers = [ + "x-gist-queue-polling-interval": "600", + "x-cio-use-sse": "false" + ] + setupHttpResponse(code: 200, body: "[]".data, headers: headers) + queueManager.fetchUserQueue(state: state) { _ in } + + state = await inAppMessageManager.waitForState { state in + state.useSse == false + } + XCTAssertEqual(state.useSse, false) + } + + // MARK: - SSE Connection Manager Integration Tests + + func test_sseFlagEnabled_expectConnectionManagerStartConnectionCalled() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + // Enable SSE flag + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + + // Wait for state to update + let state = await inAppMessageManager.waitForState { state in + state.useSse == true + } + + XCTAssertEqual(state.useSse, true) + + // Note: SseConnectionManager handles duplicate startConnection calls gracefully + // Multiple calls to enable SSE will be idempotent in the connection manager + } + + func test_sseFlagDisabled_expectConnectionManagerStopConnectionCalled() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + // Enable SSE first + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + var state = await inAppMessageManager.waitForState { state in + state.useSse == true + } + XCTAssertEqual(state.useSse, true) + + // Then disable SSE + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: false)) + state = await inAppMessageManager.waitForState { state in + state.useSse == false + } + + XCTAssertEqual(state.useSse, false) + + // Note: In a real test, we would verify that SseConnectionManager.stopConnection was called + // For Phase 1, we're just verifying the state change triggers the handler + } + + // MARK: - Polling and SSE Coordination Tests + + func test_sseFlagEnabled_expectPollingStops() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + // Start polling by fetching messages + gist.fetchUserMessagesFromRemoteQueue() + + // Enable SSE - this should stop polling + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + + let state = await inAppMessageManager.waitForState { state in + state.useSse == true + } + + XCTAssertEqual(state.useSse, true) + // Note: In Phase 1, we verify the state change. In Phase 2+, we would verify: + // - Polling timer is invalidated + // - No more fetch calls are made while SSE is enabled + } + + func test_sseFlagDisabled_expectPollingResumes() async { + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: .random)) + + // Enable SSE (which stops polling) + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + var state = await inAppMessageManager.waitForState { state in + state.useSse == true + } + XCTAssertEqual(state.useSse, true) + + // Disable SSE - this should resume polling + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: false)) + state = await inAppMessageManager.waitForState { state in + state.useSse == false + } + + XCTAssertEqual(state.useSse, false) + // Note: In Phase 1, we verify the state change. In Phase 2+, we would verify: + // - Polling timer is restarted + // - Fetch calls resume at the polling interval + } + + // MARK: - SSE Message Queue Processing After Dismissal Tests + + func test_dismissMessage_givenSseEnabled_expectNextMessageLoaded() async throws { + // Setup: Initialize with SSE enabled and identified user + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: "testUser")) + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + + // Add multiple messages to queue with different priorities + let message1 = Message(priority: 1, queueId: "message1") + let message2 = Message(priority: 2, queueId: "message2") + let message3 = Message(priority: 3, queueId: "message3") + + await inAppMessageManager.dispatchAsync(action: .processMessageQueue(messages: [message1, message2, message3])) + + // Wait for the first message (highest priority) to be loaded + var state = await inAppMessageManager.waitForState { state in + state.modalMessageState.isLoading + } + XCTAssertEqual(state.modalMessageState, .loading(message: message1)) + + // Display and dismiss the first message + await inAppMessageManager.dispatchAsync(action: .displayMessage(message: message1)) + try await dispatchAndWait(.dismissMessage(message: message1)) + + // Verify: With SSE enabled, the next message (message2) should be loaded automatically + state = await inAppMessageManager.waitForState { state in + if case .loading(let msg) = state.modalMessageState { + return msg.queueId == "message2" + } + return false + } + + if case .loading(let loadedMessage) = state.modalMessageState { + XCTAssertEqual(loadedMessage.queueId, "message2", "With SSE enabled, next message should be loaded after dismissal") + } else { + XCTFail("Expected loading state with message2 after dismissing message1 with SSE enabled") + } + } + + func test_dismissMessage_givenSseDisabled_expectNoAutoLoadNextMessage() async throws { + // Setup: Initialize WITHOUT SSE (polling mode) + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: "testUser")) + // SSE is disabled by default, but let's be explicit + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: false)) + + // Add multiple messages to queue + let message1 = Message(priority: 1, queueId: "message1") + let message2 = Message(priority: 2, queueId: "message2") + + await inAppMessageManager.dispatchAsync(action: .processMessageQueue(messages: [message1, message2])) + + // Wait for the first message to be loaded + var state = await inAppMessageManager.waitForState { state in + state.modalMessageState.isLoading + } + XCTAssertEqual(state.modalMessageState, .loading(message: message1)) + + // Display and dismiss the first message + await inAppMessageManager.dispatchAsync(action: .displayMessage(message: message1)) + await inAppMessageManager.dispatchAsync(action: .dismissMessage(message: message1)) + + // Verify: With SSE disabled, the state should remain dismissed (no auto-load of next message) + state = await inAppMessageManager.state + XCTAssertEqual(state.modalMessageState, .dismissed(message: message1), "With SSE disabled, state should remain dismissed without auto-loading next message") + } + + func test_dismissMessage_givenSseFlagTrueButAnonymousUser_expectNoAutoLoadNextMessage() async throws { + // Setup: Initialize with SSE flag true but only anonymousId (no userId) + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setAnonymousIdentifier(anonymousId: "anonymous123")) + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + + var state = await inAppMessageManager.state + // Verify shouldUseSse is false because user is not identified + XCTAssertTrue(state.useSse, "SSE flag should be true") + XCTAssertFalse(state.shouldUseSse, "shouldUseSse should be false for anonymous users") + + // Add multiple messages to queue + let message1 = Message(priority: 1, queueId: "message1") + let message2 = Message(priority: 2, queueId: "message2") + + await inAppMessageManager.dispatchAsync(action: .processMessageQueue(messages: [message1, message2])) + + // Wait for the first message to be loaded + state = await inAppMessageManager.waitForState { state in + state.modalMessageState.isLoading + } + XCTAssertEqual(state.modalMessageState, .loading(message: message1)) + + // Display and dismiss the first message + await inAppMessageManager.dispatchAsync(action: .displayMessage(message: message1)) + await inAppMessageManager.dispatchAsync(action: .dismissMessage(message: message1)) + + // Verify: With anonymous user (shouldUseSse=false), the state should remain dismissed + state = await inAppMessageManager.state + XCTAssertEqual(state.modalMessageState, .dismissed(message: message1), "With anonymous user, state should remain dismissed without auto-loading next message") + } + + func test_dismissMessage_givenSseEnabledAndMultipleMessages_expectMessagesProcessedInPriorityOrder() async throws { + // Setup: Initialize with SSE enabled and identified user + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: "testUser")) + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + + // Add messages with different priorities (lower number = higher priority) + let message1 = Message(priority: 1, queueId: "highPriority") + let message2 = Message(priority: 2, queueId: "mediumPriority") + let message3 = Message(priority: 3, queueId: "lowPriority") + + await inAppMessageManager.dispatchAsync(action: .processMessageQueue(messages: [message3, message1, message2])) // Add in random order + + // First message (highest priority) should be loaded + var state = await inAppMessageManager.waitForState { state in + state.modalMessageState.isLoading + } + XCTAssertEqual(state.modalMessageState, .loading(message: message1)) + + // Display and dismiss first message + await inAppMessageManager.dispatchAsync(action: .displayMessage(message: message1)) + try await dispatchAndWait(.dismissMessage(message: message1)) + + // Second message (medium priority) should be loaded + state = await inAppMessageManager.waitForState { state in + if case .loading(let msg) = state.modalMessageState { + return msg.queueId == "mediumPriority" + } + return false + } + XCTAssertEqual(state.modalMessageState, .loading(message: message2)) + + // Display and dismiss second message + await inAppMessageManager.dispatchAsync(action: .displayMessage(message: message2)) + try await dispatchAndWait(.dismissMessage(message: message2)) + + // Third message (low priority) should be loaded + state = await inAppMessageManager.waitForState { state in + if case .loading(let msg) = state.modalMessageState { + return msg.queueId == "lowPriority" + } + return false + } + XCTAssertEqual(state.modalMessageState, .loading(message: message3)) + } + + func test_dismissMessage_givenSseEnabledAndAlreadyShownMessage_expectMessageNotLoadedAgain() async throws { + // Setup: Initialize with SSE enabled + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: "testUser")) + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + + // Add two messages + let message1 = Message(priority: 1, queueId: "message1") + let message2 = Message(priority: 2, queueId: "message2") + + await inAppMessageManager.dispatchAsync(action: .processMessageQueue(messages: [message1, message2])) + + // Wait for message1 to load, display it, and dismiss it + var state = await inAppMessageManager.waitForState { state in + state.modalMessageState.isLoading + } + await inAppMessageManager.dispatchAsync(action: .displayMessage(message: message1)) + try await dispatchAndWait(.dismissMessage(message: message1)) + + // Wait for message2 to load, display it, and dismiss it + state = await inAppMessageManager.waitForState { state in + if case .loading(let msg) = state.modalMessageState { + return msg.queueId == "message2" + } + return false + } + await inAppMessageManager.dispatchAsync(action: .displayMessage(message: message2)) + try await dispatchAndWait(.dismissMessage(message: message2)) + + // Verify: Both messages should be in shownMessageQueueIds + state = await inAppMessageManager.state + XCTAssertTrue(state.shownMessageQueueIds.contains("message1"), "message1 should be marked as shown") + XCTAssertTrue(state.shownMessageQueueIds.contains("message2"), "message2 should be marked as shown") + + // State should remain dismissed (no more messages to show) + XCTAssertEqual(state.modalMessageState, .dismissed(message: message2)) + } + + func test_dismissMessage_givenSseEnabledWithPageRule_expectOnlyMatchingMessageLoaded() async throws { + // Setup: Initialize with SSE enabled and set route + await inAppMessageManager.dispatchAsync(action: .initialize(siteId: .random, dataCenter: .random, environment: .production)) + await inAppMessageManager.dispatchAsync(action: .setUserIdentifier(user: "testUser")) + await inAppMessageManager.dispatchAsync(action: .setSseEnabled(enabled: true)) + await inAppMessageManager.dispatchAsync(action: .setPageRoute(route: "home")) + + // Add messages: one without page rule, one matching route, one not matching + let messageNoRule = Message(priority: 1, queueId: "noRule") + let messageHomeRoute = Message(priority: 2, pageRule: "home", queueId: "homeRoute") + let messageProfileRoute = Message(priority: 3, pageRule: "profile", queueId: "profileRoute") + + await inAppMessageManager.dispatchAsync(action: .processMessageQueue(messages: [messageNoRule, messageHomeRoute, messageProfileRoute])) + + // First message (no page rule, highest priority) should be loaded + var state = await inAppMessageManager.waitForState { state in + state.modalMessageState.isLoading + } + XCTAssertEqual(state.modalMessageState, .loading(message: messageNoRule)) + + // Display and dismiss first message + await inAppMessageManager.dispatchAsync(action: .displayMessage(message: messageNoRule)) + try await dispatchAndWait(.dismissMessage(message: messageNoRule)) + + // Second message (matching home route) should be loaded + state = await inAppMessageManager.waitForState { state in + if case .loading(let msg) = state.modalMessageState { + return msg.queueId == "homeRoute" + } + return false + } + XCTAssertEqual(state.modalMessageState, .loading(message: messageHomeRoute)) + + // Display and dismiss second message + await inAppMessageManager.dispatchAsync(action: .displayMessage(message: messageHomeRoute)) + try await dispatchAndWait(.dismissMessage(message: messageHomeRoute)) + + // Verify: profileRoute message should NOT be loaded since route doesn't match + state = await inAppMessageManager.state + // The messageProfileRoute should not be loaded since current route is "home" not "profile" + XCTAssertEqual(state.modalMessageState, .dismissed(message: messageHomeRoute), "Message with non-matching page rule should not be auto-loaded") + XCTAssertFalse(state.shownMessageQueueIds.contains("profileRoute"), "profileRoute message should not have been shown") + } } extension InAppMessageManager {