diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6f8d419..cb9a5c5b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,12 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Added at least once delivery for JSON-only in-app messages through `IterableInAppDelegate.onJsonOnlyMessageAvailable(message:)` and `iterableJsonOnlyInAppMessageAvailable` (Objective-C: `IterableAPI.jsonOnlyInAppMessageAvailableNotification`). Messages are saved to local storage before signaling and replay on foreground until acknowledged. Unhandled messages remain available through `IterableAPI.getUnhandledJsonOnlyMessages()` until acknowledged with `markJsonOnlyMessageHandled(messageId:)`; acknowledgement records a payload fingerprint (bounded to the latest 100 per user) so the same message ID with a changed payload is delivered again. Callbacks are invoked without SDK locks held; see the API documentation for the identity overlap contract. + ### Fixed +- Public `inAppConsume` APIs now remove messages locally and post `iterableInboxChanged` after the local state is updated. The notification fires only when an inbox message changes, so removing popups or JSON-only messages no longer announces an inbox change. +- In-app fetch, delivery, and merge are now scoped to the user identity that started them, so a login or logout during processing can no longer deliver or persist the previous user's messages. - Fixed offline-queued requests replaying an expired JWT forever. Tasks persisted while the token was expired kept the stale token in their payload, so they failed with a 401 on every retry even after a successful refresh, and could block the rest of the offline queue. The task processor now stamps the current auth token at execution time, matching online behavior, which also heals tasks already stuck in the queue. ## [6.7.4] diff --git a/swift-sdk/Core/Constants.swift b/swift-sdk/Core/Constants.swift index 97215deb7..4caf23d1b 100644 --- a/swift-sdk/Core/Constants.swift +++ b/swift-sdk/Core/Constants.swift @@ -96,6 +96,7 @@ enum Const { static let visitorConsentTimestamp = "itbl_visitor_consent_timestamp" static let isNotificationsEnabled = "itbl_isNotificationsEnabled" static let hasStoredNotificationSetting = "itbl_hasStoredNotificationSetting" + static let jsonOnlyMessageQueue = "itbl_json_only_message_queue" static let attributionInfoExpiration = 24 } diff --git a/swift-sdk/Internal/Auth.swift b/swift-sdk/Internal/Auth.swift index f63a51b83..1a6dc8b5d 100644 --- a/swift-sdk/Internal/Auth.swift +++ b/swift-sdk/Internal/Auth.swift @@ -8,6 +8,86 @@ protocol AuthProvider: AnyObject { var auth: Auth { get } } +struct UserIdentityContext: Equatable { + let identity: UserIdentitySnapshot? + let generation: UInt64 +} + +final class IdentityCoordinator { + func capture(identityProvider: () -> UserIdentitySnapshot?) -> UserIdentityContext { + withCriticalSection { + UserIdentityContext(identity: identityProvider(), generation: generation) + } + } + + func isCurrent(_ context: UserIdentityContext, + identityProvider: () -> UserIdentitySnapshot?) -> Bool { + withCriticalSection { + context.generation == generation && + context.identity == identityProvider() && + !hasPendingPublication + } + } + + @discardableResult + func performIfCurrent(_ context: UserIdentityContext, + identityProvider: () -> UserIdentitySnapshot?, + _ block: () -> Void) -> Bool { + withCriticalSection { + guard context.generation == generation, + context.identity == identityProvider(), + !hasPendingPublication else { + return false + } + block() + return context.generation == generation && + context.identity == identityProvider() && + !hasPendingPublication + } + } + + func publish(_ block: () -> Void) { + // Announce before waiting for the identity lock so stale in-flight checks fail while publication is queued. + beginPublication() + withCriticalSection { + block() + generation &+= 1 + endPublication() + } + } + + func beginPublication() { + pendingPublicationLock.lock() + pendingPublicationCount += 1 + pendingPublicationLock.unlock() + } + + func endPublication() { + pendingPublicationLock.lock() + pendingPublicationCount -= 1 + pendingPublicationLock.unlock() + } + + func withCriticalSection(_ block: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return block() + } + + private var hasPendingPublication: Bool { + pendingPublicationLock.lock() + defer { pendingPublicationLock.unlock() } + return pendingPublicationCount > 0 + } + + // Lock order is manager queue, identity section, then JSON store queue. Identity + // holders must not call customer code or synchronously wait on manager queues. + private let lock = NSRecursiveLock() + private let pendingPublicationLock = NSLock() + private var generation: UInt64 = 0 + private var pendingPublicationCount = 0 +} + struct Auth { let userId: String? let email: String? diff --git a/swift-sdk/Internal/EmptyInAppManager.swift b/swift-sdk/Internal/EmptyInAppManager.swift index 26a584757..5c0fd4daf 100644 --- a/swift-sdk/Internal/EmptyInAppManager.swift +++ b/swift-sdk/Internal/EmptyInAppManager.swift @@ -10,6 +10,16 @@ class EmptyInAppManager: IterableInternalInAppManagerProtocol { func start() -> Pending { Fulfill(value: true) } + + func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] { + [] + } + + func markJsonOnlyMessageHandled(messageId _: String) -> Bool { + false + } + + func clearUnhandledJsonOnlyMessages() {} func handleClick(clickedUrl _: URL?, forMessage _: IterableInAppMessage, location _: InAppLocation, inboxSessionId _: String?) {} diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index f5c1e3b41..a25be7953 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -18,7 +18,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { var email: String? { get { - _email + identityValues().email } set { setEmail(newValue) } @@ -26,7 +26,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { var userId: String? { get { - _userId + identityValues().userId } set { setUserId(newValue) } @@ -78,7 +78,11 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } var auth: Auth { - Auth(userId: userId, email: email, authToken: authManager.getAuthToken(), userIdUnknownUser: localStorage.userIdUnknownUser) + let identity = identityValues() + return Auth(userId: identity.userId, + email: identity.email, + authToken: authManager.getAuthToken(), + userIdUnknownUser: localStorage.userIdUnknownUser) } var dependencyContainer: DependencyContainerProtocol @@ -88,6 +92,8 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { apiClient: self.apiClient, requestHandler: self.requestHandler, deviceMetadata: deviceMetadata, + authProvider: self, + identityCoordinator: self.identityCoordinator, authManager: self.authManager) }() @@ -145,21 +151,26 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } func setEmail(_ email: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, identityResolution: IterableIdentityResolution? = nil) { + // Span previous-user logout/reset and replacement identity publication so stale work cannot commit between them. + identityCoordinator.beginPublication() ITBInfo() - if self._email == email && email != nil { + let currentEmail = identityValues().email + if currentEmail == email && email != nil { + identityCoordinator.endPublication() self.checkAndUpdateAuthToken(authToken) return } - if self._email == email { + if currentEmail == email { + identityCoordinator.endPublication() return } self.logoutPreviousUser() - self._email = email - self._userId = nil + setIdentity(email: email, userId: nil) + identityCoordinator.endPublication() self.onLogin(authToken) { [weak self] in guard let config = self?.config else { @@ -194,21 +205,27 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } func setUserId(_ userId: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, isUnknownUser: Bool = false, identityResolution: IterableIdentityResolution? = nil) { + // Span previous-user logout/reset and replacement identity publication so stale work cannot commit between them. + identityCoordinator.beginPublication() + ITBInfo() - if self._userId == userId && userId != nil { + let currentUserId = identityValues().userId + if currentUserId == userId && userId != nil { + identityCoordinator.endPublication() self.checkAndUpdateAuthToken(authToken) return } - if self._userId == userId { + if currentUserId == userId { + identityCoordinator.endPublication() return } self.logoutPreviousUser() - self._email = nil - self._userId = userId + setIdentity(email: nil, userId: userId) + identityCoordinator.endPublication() self.onLogin(authToken) { [weak self] in guard let config = self?.config else { @@ -253,9 +270,13 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { func logoutUser(withOnSuccess onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { + // Announce logout before waiting for the identity lock so stale work stops while publication is queued. + identityCoordinator.beginPublication() + ITBInfo() guard isSDKInitialized() else { + identityCoordinator.endPublication() onFailure?("Iterable SDK is not initialized", nil) return } @@ -264,8 +285,10 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure) } - _email = nil - _userId = nil + setIdentity(email: nil, userId: nil) + identityCoordinator.endPublication() + + inAppManager.clearUnhandledJsonOnlyMessages() storeIdentifierData() @@ -748,6 +771,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { onSuccess: onSuccess, onFailure: onFailure) } + + func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] { + inAppManager.getUnhandledJsonOnlyMessages() + } + + func markJsonOnlyMessageHandled(messageId: String) -> Bool { + inAppManager.markJsonOnlyMessageHandled(messageId: messageId) + } @discardableResult func track(embeddedMessageReceived message: IterableEmbeddedMessage, @@ -799,6 +830,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { private var _email: String? private var _payloadData: [AnyHashable: Any]? private var _userId: String? + private let identityCoordinator = IdentityCoordinator() private var _successCallback: OnSuccessHandler? = nil private var _failureCallback: OnFailureHandler? = nil @@ -863,11 +895,13 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } public func isEitherUserIdOrEmailSet() -> Bool { - IterableUtil.isNotNullOrEmpty(string: _email) || IterableUtil.isNotNullOrEmpty(string: _userId) + let identity = identityValues() + return IterableUtil.isNotNullOrEmpty(string: identity.email) || IterableUtil.isNotNullOrEmpty(string: identity.userId) } public func noUserLoggedIn() -> Bool { - IterableUtil.isNullOrEmpty(string: _email) && IterableUtil.isNullOrEmpty(string: _userId) + let identity = identityValues() + return IterableUtil.isNullOrEmpty(string: identity.email) && IterableUtil.isNullOrEmpty(string: identity.userId) } public func isUnknownUserSet() -> Bool { @@ -875,17 +909,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } private func logoutPreviousUser() { - // Delegates to logoutUser(withOnSuccess:onFailure:) so the logout cleanup - // sequence has a single source of truth. The user-switch paths (setEmail/ - // setUserId) pass no handlers: a nil onFailure keeps the not-initialized - // guard a silent no-op, and a nil onSuccess makes the auto-push-off - // completion a no-op, matching this method's previous behavior. + // Preserve the existing no-handler behavior for internal user switches. logoutUser(withOnSuccess: nil, onFailure: nil) } private func storeIdentifierData() { - localStorage.email = _email - localStorage.userId = _userId + let identity = identityValues() + localStorage.email = identity.email + localStorage.userId = identity.userId } private func onLogin(_ authToken: String? = nil, onloginSuccess onloginSuccessCallBack: (()->())? = nil) { @@ -941,8 +972,18 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } private func retrieveIdentifierData() { - _email = localStorage.email - _userId = localStorage.userId + setIdentity(email: localStorage.email, userId: localStorage.userId) + } + + private func identityValues() -> (email: String?, userId: String?) { + identityCoordinator.withCriticalSection { (_email, _userId) } + } + + private func setIdentity(email: String?, userId: String?) { + identityCoordinator.publish { + _email = email + _userId = userId + } } private func save(pushPayload payload: [AnyHashable: Any]) { @@ -1182,4 +1223,3 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } - diff --git a/swift-sdk/Internal/IterableUserDefaults.swift b/swift-sdk/Internal/IterableUserDefaults.swift index 2836655e7..5476cc0ee 100644 --- a/swift-sdk/Internal/IterableUserDefaults.swift +++ b/swift-sdk/Internal/IterableUserDefaults.swift @@ -211,6 +211,14 @@ class IterableUserDefaults { save(bool: newValue, withKey: .hasStoredNotificationSetting) } } + + var jsonOnlyMessageQueueData: Data? { + get { + userDefaults.data(forKey: UserDefaultsKey.jsonOnlyMessageQueue.value) + } set { + userDefaults.set(newValue, forKey: UserDefaultsKey.jsonOnlyMessageQueue.value) + } + } func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { (try? codable(withKey: .attributionInfo, currentDate: currentDate)) ?? nil @@ -389,6 +397,7 @@ class IterableUserDefaults { static let isNotificationsEnabled = UserDefaultsKey(value: Const.UserDefault.isNotificationsEnabled) static let hasStoredNotificationSetting = UserDefaultsKey(value: Const.UserDefault.hasStoredNotificationSetting) + static let jsonOnlyMessageQueue = UserDefaultsKey(value: Const.UserDefault.jsonOnlyMessageQueue) } private struct Envelope: Codable { let payload: Data diff --git a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift index 645437f68..8d1b00dc9 100644 --- a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift +++ b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift @@ -34,22 +34,34 @@ extension DependencyContainerProtocol { apiClient: ApiClientProtocol, requestHandler: RequestHandlerProtocol, deviceMetadata: DeviceMetadata, + authProvider: AuthProvider, + identityCoordinator: IdentityCoordinator, authManager: IterableAuthManagerProtocol?) -> IterableInternalInAppManagerProtocol { - InAppManager(requestHandler: requestHandler, - deviceMetadata: deviceMetadata, - fetcher: createInAppFetcher(apiClient: apiClient, authManager: authManager), - displayer: inAppDisplayer, - persister: inAppPersister, - inAppDelegate: config.inAppDelegate, - inAppDisplayDelegate: config.inAppDisplayDelegate, - urlDelegate: config.urlDelegate, - customActionDelegate: config.customActionDelegate, - urlOpener: urlOpener, - allowedProtocols: config.allowedProtocols, - applicationStateProvider: applicationStateProvider, - notificationCenter: notificationCenter, - dateProvider: dateProvider, - moveToForegroundSyncInterval: config.inAppDisplayInterval) + let identityProvider = { [weak authProvider] in + UserIdentitySnapshot(auth: authProvider?.auth) + } + let jsonOnlyMessageStore = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: identityProvider, + identityCoordinator: identityCoordinator) + return InAppManager(requestHandler: requestHandler, + deviceMetadata: deviceMetadata, + fetcher: createInAppFetcher(apiClient: apiClient, authManager: authManager), + displayer: inAppDisplayer, + persister: inAppPersister, + inAppDelegate: config.inAppDelegate, + inAppDisplayDelegate: config.inAppDisplayDelegate, + urlDelegate: config.urlDelegate, + customActionDelegate: config.customActionDelegate, + urlOpener: urlOpener, + allowedProtocols: config.allowedProtocols, + applicationStateProvider: applicationStateProvider, + notificationCenter: notificationCenter, + dateProvider: dateProvider, + jsonOnlyMessageStore: jsonOnlyMessageStore, + identityCoordinator: identityCoordinator, + identityProvider: identityProvider, + moveToForegroundSyncInterval: config.inAppDisplayInterval) } func createAuthManager(config: IterableConfig) -> IterableAuthManagerProtocol { diff --git a/swift-sdk/Internal/Utilities/LocalStorage.swift b/swift-sdk/Internal/Utilities/LocalStorage.swift index 83e64946e..6b095cd65 100644 --- a/swift-sdk/Internal/Utilities/LocalStorage.swift +++ b/swift-sdk/Internal/Utilities/LocalStorage.swift @@ -154,6 +154,14 @@ struct LocalStorage: LocalStorageProtocol { iterableUserDefaults.hasStoredNotificationSetting = newValue } } + + var jsonOnlyMessageQueueData: Data? { + get { + iterableUserDefaults.jsonOnlyMessageQueueData + } set { + iterableUserDefaults.jsonOnlyMessageQueueData = newValue + } + } func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { iterableUserDefaults.getAttributionInfo(currentDate: currentDate) diff --git a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift index 7a479c945..8676b5953 100644 --- a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift +++ b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift @@ -38,6 +38,8 @@ protocol LocalStorageProtocol { var isNotificationsEnabled: Bool { get set } var hasStoredNotificationSetting: Bool { get set } + + var jsonOnlyMessageQueueData: Data? { get set } func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? diff --git a/swift-sdk/Internal/in-app/InAppManager+Functions.swift b/swift-sdk/Internal/in-app/InAppManager+Functions.swift index 8e0c93fad..9ed5bb4d7 100644 --- a/swift-sdk/Internal/in-app/InAppManager+Functions.swift +++ b/swift-sdk/Internal/in-app/InAppManager+Functions.swift @@ -7,17 +7,22 @@ import Foundation enum MessagesProcessorResult { case show(message: IterableInAppMessage, messagesMap: OrderedDictionary) case noShow(message: IterableInAppMessage?, messagesMap: OrderedDictionary) + case jsonOnly(message: IterableInAppMessage, messagesMap: OrderedDictionary) } struct MessagesProcessor { init(inAppDelegate: IterableInAppDelegate, inAppDisplayChecker: InAppDisplayChecker, - messagesMap: OrderedDictionary) { + messagesMap: OrderedDictionary, + currentDate: Date, + isContextCurrent: @escaping () -> Bool) { ITBInfo() self.inAppDelegate = inAppDelegate self.inAppDisplayChecker = inAppDisplayChecker self.messagesMap = messagesMap + self.currentDate = currentDate + self.isContextCurrent = isContextCurrent } mutating func processMessages() -> MessagesProcessorResult { @@ -30,9 +35,8 @@ struct MessagesProcessor { case let .skip(message): updateMessage(message, didProcessTrigger: true) return processMessages() - case let .skipAndConsume(message): - updateMessage(message, didProcessTrigger: true, consumed: true) - return .noShow(message: message, messagesMap: messagesMap) + case let .jsonOnly(message): + return .jsonOnly(message: message, messagesMap: messagesMap) case .none, .wait: return .noShow(message: nil, messagesMap: messagesMap) } @@ -41,7 +45,7 @@ struct MessagesProcessor { private enum ProcessNextMessageResult { case show(IterableInAppMessage) case skip(IterableInAppMessage) - case skipAndConsume(IterableInAppMessage) + case jsonOnly(IterableInAppMessage) case none case wait } @@ -56,17 +60,23 @@ struct MessagesProcessor { ITBDebug("processing message with id: \(message.messageId)") + // JSON-only availability intentionally bypasses the HTML display pause and cooldown. + if message.isJsonOnly { + return .jsonOnly(message) + } + + guard isContextCurrent() else { return .none } + guard inAppDisplayChecker.isOkToShowNow(message: message) else { ITBDebug("Not ok to show now") return .wait } ITBDebug("isOkToShowNow") - + + guard isContextCurrent() else { return .none } + let returnValue = inAppDelegate.onNew(message: message) - if message.isJsonOnly { - return .skipAndConsume(message) - } if returnValue == .show { ITBDebug("delegate returned show") return .show(message) @@ -77,14 +87,17 @@ struct MessagesProcessor { } private func getFirstProcessableTriggeredMessage() -> IterableInAppMessage? { - messagesMap.values - .filter(MessagesProcessor.isProcessableTriggeredMessage) - .sorted { $0.priorityLevel < $1.priorityLevel } - .first + let processableMessages = messagesMap.values.filter(isProcessableTriggeredMessage) + // Select JSON-only records before applying HTML priority ordering. + return processableMessages.first(where: { $0.isJsonOnly }) + ?? processableMessages.sorted { $0.priorityLevel < $1.priorityLevel }.first } - private static func isProcessableTriggeredMessage(_ message: IterableInAppMessage) -> Bool { - !message.didProcessTrigger && message.trigger.type == .immediate && !message.read + private func isProcessableTriggeredMessage(_ message: IterableInAppMessage) -> Bool { + !message.didProcessTrigger && + message.trigger.type == .immediate && + !message.read && + (message.expiresAt.map { $0 > currentDate } ?? true) } private mutating func updateMessage(_ message: IterableInAppMessage, didProcessTrigger: Bool? = nil, consumed: Bool? = nil) { @@ -106,6 +119,8 @@ struct MessagesProcessor { private let inAppDelegate: IterableInAppDelegate private let inAppDisplayChecker: InAppDisplayChecker private var messagesMap: OrderedDictionary + private let currentDate: Date + private let isContextCurrent: () -> Bool } struct MergeMessagesResult { @@ -116,10 +131,15 @@ struct MergeMessagesResult { /// Merges the results and determines whether inbox changed needs to be fired. struct MessagesObtainedHandler { - init(messagesMap: OrderedDictionary, messages: [IterableInAppMessage]) { + init(messagesMap: OrderedDictionary, + messages: [IterableInAppMessage], + acknowledgedJsonOnlyMessageIds: Set = [], + readmittedJsonOnlyMessageIds: Set = []) { ITBInfo() self.messagesMap = messagesMap self.messages = messages + self.acknowledgedJsonOnlyMessageIds = acknowledgedJsonOnlyMessageIds + self.readmittedJsonOnlyMessageIds = readmittedJsonOnlyMessageIds } func handle() -> MergeMessagesResult { @@ -131,22 +151,54 @@ struct MessagesObtainedHandler { let addedInboxCount = addedMessages.reduce(0) { $1.saveToInbox ? $0 + 1 : $0 } var messagesOverwritten = 0 + var readmittedMessages = [IterableInAppMessage]() var newMessagesMap = OrderedDictionary() messages.forEach { serverMessage in let messageId = serverMessage.messageId if let existingMessage = messagesMap[messageId] { - if Self.shouldOverwrite(clientMessage: existingMessage, withServerMessage: serverMessage) { + // Handle acknowledged HTML-to-JSON transitions before generic type replacement to avoid readmission. + if !existingMessage.isJsonOnly, + serverMessage.isJsonOnly, + acknowledgedJsonOnlyMessageIds.contains(messageId) { + serverMessage.consumed = true + serverMessage.didProcessTrigger = true + newMessagesMap[messageId] = serverMessage + if existingMessage.saveToInbox { + messagesOverwritten += 1 + } + } else if existingMessage.isJsonOnly != serverMessage.isJsonOnly { + newMessagesMap[messageId] = serverMessage + readmittedMessages.append(serverMessage) + if existingMessage.saveToInbox || serverMessage.saveToInbox { + messagesOverwritten += 1 + } + } else if serverMessage.isJsonOnly && readmittedJsonOnlyMessageIds.contains(messageId) { + newMessagesMap[messageId] = serverMessage + readmittedMessages.append(serverMessage) + } else if serverMessage.isJsonOnly && acknowledgedJsonOnlyMessageIds.contains(messageId) { + existingMessage.consumed = true + existingMessage.didProcessTrigger = true + newMessagesMap[messageId] = existingMessage + } else if Self.shouldOverwrite(clientMessage: existingMessage, withServerMessage: serverMessage) { + serverMessage.consumed = existingMessage.consumed + serverMessage.didProcessTrigger = existingMessage.didProcessTrigger newMessagesMap[messageId] = serverMessage messagesOverwritten += 1 } else { newMessagesMap[messageId] = existingMessage } } else { + if serverMessage.isJsonOnly && acknowledgedJsonOnlyMessageIds.contains(messageId) { + serverMessage.consumed = true + serverMessage.didProcessTrigger = true + } newMessagesMap[messageId] = serverMessage } } - let deliveredMessages = addedMessages.filter { $0.read != true } + let deliveredMessages = (addedMessages + readmittedMessages).filter { + !$0.read && !acknowledgedJsonOnlyMessageIds.contains($0.messageId) + } return MergeMessagesResult(inboxChanged: removedInboxCount + addedInboxCount + messagesOverwritten > 0, messagesMap: newMessagesMap, @@ -155,6 +207,8 @@ struct MessagesObtainedHandler { private let messagesMap: OrderedDictionary private let messages: [IterableInAppMessage] + private let acknowledgedJsonOnlyMessageIds: Set + private let readmittedJsonOnlyMessageIds: Set // We should only overwrite if the server is read and client is not read. // This is because some client changes may not have propagated to server yet. diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index 0e5c7ef76..914234db5 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -11,6 +11,10 @@ protocol InAppDisplayChecker { protocol IterableInternalInAppManagerProtocol: IterableInAppManagerProtocol, InAppNotifiable, InAppDisplayChecker { func start() -> Pending + + func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] + func markJsonOnlyMessageHandled(messageId: String) -> Bool + func clearUnhandledJsonOnlyMessages() /// Use this method to handle clicks in InApp Messages /// - parameter clickedUrl: The url that is clicked. @@ -51,6 +55,9 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { applicationStateProvider: ApplicationStateProviderProtocol, notificationCenter: NotificationCenterProtocol, dateProvider: DateProviderProtocol, + jsonOnlyMessageStore: JsonOnlyMessageStore, + identityCoordinator: IdentityCoordinator, + identityProvider: @escaping () -> UserIdentitySnapshot?, moveToForegroundSyncInterval: Double) { ITBInfo() @@ -68,11 +75,15 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { self.applicationStateProvider = applicationStateProvider self.notificationCenter = notificationCenter self.dateProvider = dateProvider + self.jsonOnlyMessageStore = jsonOnlyMessageStore + self.identityCoordinator = identityCoordinator + self.identityProvider = identityProvider self.moveToForegroundSyncInterval = moveToForegroundSyncInterval super.init() initializeMessagesMap() + messagesIdentityContext = identityCoordinator.capture(identityProvider: identityProvider) self.notificationCenter.addObserver(self, selector: #selector(onAppEnteredForeground(notification:)), @@ -104,19 +115,47 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { func getMessages() -> [IterableInAppMessage] { ITBInfo() - - return Array(messagesMap.values.filter { InAppManager.isValid(message: $0, currentDate: self.dateProvider.currentDate) }) + + var messages = [IterableInAppMessage]() + let didRead = identityCoordinator.withCriticalSection { + guard let context = messagesIdentityContext else { return false } + return identityCoordinator.performIfCurrent(context, identityProvider: identityProvider) { + messages = Array(messagesMap.values.filter { InAppManager.isValid(message: $0, currentDate: self.dateProvider.currentDate) }) + } + } + guard didRead else { return [] } + return messages } func getInboxMessages() -> [IterableInAppMessage] { ITBInfo() - - return Array(messagesMap.values.filter { InAppManager.isValid(message: $0, currentDate: self.dateProvider.currentDate) && $0.saveToInbox }) + + var messages = [IterableInAppMessage]() + let didRead = identityCoordinator.withCriticalSection { + guard let context = messagesIdentityContext else { return false } + return identityCoordinator.performIfCurrent(context, identityProvider: identityProvider) { + messages = Array(messagesMap.values.filter { InAppManager.isValid(message: $0, currentDate: self.dateProvider.currentDate) && $0.saveToInbox }) + } + } + guard didRead else { return [] } + return messages } func getUnreadInboxMessagesCount() -> Int { getInboxMessages().filter { $0.read == false }.count } + + func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] { + jsonOnlyMessageStore.getMessages() + } + + func markJsonOnlyMessageHandled(messageId: String) -> Bool { + jsonOnlyMessageStore.remove(messageId: messageId) + } + + func clearUnhandledJsonOnlyMessages() { + jsonOnlyMessageStore.clear() + } func show(message: IterableInAppMessage) { ITBInfo() @@ -169,6 +208,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { func set(read: Bool, forMessage message: IterableInAppMessage, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil) { updateMessage(message, read: read).onSuccess { [weak self] _ in successHandler?([:]) + guard message.saveToInbox else { return } self?.callbackQueue.async { [weak self] in self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) } @@ -178,7 +218,15 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } func getMessage(withId id: String) -> IterableInAppMessage? { - messagesMap[id] + var message: IterableInAppMessage? + let didRead = identityCoordinator.withCriticalSection { + guard let context = messagesIdentityContext else { return false } + return identityCoordinator.performIfCurrent(context, identityProvider: identityProvider) { + message = messagesMap[id] + } + } + guard didRead else { return nil } + return message } // MARK: - IterableInternalInAppManagerProtocol @@ -192,7 +240,9 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } } - return scheduleSync() + return replayUnhandledJsonOnlyMessages().flatMap { [weak self] _ in + self?.scheduleSync() ?? Fulfill(value: true) + } } func handleClick(clickedUrl url: URL?, forMessage message: IterableInAppMessage, location: InAppLocation, inboxSessionId: String?) { @@ -227,6 +277,14 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { @objc private func onAppEnteredForeground(notification _: Notification) { ITBInfo() + + replayUnhandledJsonOnlyMessages().onSuccess { [weak self] _ in + self?.processForegroundMessages() + } + } + + private func processForegroundMessages() { + ITBInfo() let waitTime = InAppManager.getWaitTimeInterval(fromLastTime: lastSyncTime, currentTime: dateProvider.currentDate, gap: moveToForegroundSyncInterval) @@ -242,37 +300,89 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { private func synchronize(appIsReady: Bool) -> Pending { ITBInfo() + let identityContext = identityCoordinator.capture(identityProvider: identityProvider) return fetcher.fetch() - .map { [weak self] in - self?.mergeMessages($0) ?? MergeMessagesResult(inboxChanged: false, messagesMap: [:], deliveredMessages: []) - } - .map { [weak self] in - self?.processMergedMessages(appIsReady: appIsReady, mergeMessagesResult: $0) ?? true + .flatMap { [weak self] messages in + self?.processFetchedMessages(messages, + appIsReady: appIsReady, + identityContext: identityContext) ?? Fulfill(value: true) } } - - /// `messages` are new messages coming from the server - private func mergeMessages(_ messages: [IterableInAppMessage]) -> MergeMessagesResult { - MessagesObtainedHandler(messagesMap: messagesMap, messages: messages).handle() - } - - private func processMergedMessages(appIsReady: Bool, mergeMessagesResult: MergeMessagesResult) -> Bool { - if appIsReady { - processAndShowMessage(messagesMap: mergeMessagesResult.messagesMap) - } else { - messagesMap = mergeMessagesResult.messagesMap - } - - // track in-app delivery - mergeMessagesResult.deliveredMessages.forEach { - requestHandler?.track(inAppDelivery: $0, - onSuccess: nil, - onFailure: nil) + + // Merge on syncQueue, process customer/display work off it, then return for guarded tracking and persistence. + // messagesRevision prevents stale processing results from overwriting a reset. + private func processFetchedMessages(_ messages: [IterableInAppMessage], + appIsReady: Bool, + identityContext: UserIdentityContext) -> Pending { + let result = Fulfill() + + syncQueue.async { [weak self] in + guard let self = self else { + result.resolve(with: true) + return + } + + var mergeResult: MergeMessagesResult? + var processingRevision: UInt64? + let committed = self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider) { + let acknowledgementStatus = self.jsonOnlyMessageStore.acknowledgementStatus(for: messages, + identityContext: identityContext) + let merged = MessagesObtainedHandler(messagesMap: self.messagesMap, + messages: messages, + acknowledgedJsonOnlyMessageIds: acknowledgementStatus.unchangedMessageIds, + readmittedJsonOnlyMessageIds: acknowledgementStatus.changedMessageIds).handle() + self.messagesMap = merged.messagesMap + self.messagesIdentityContext = identityContext + processingRevision = self.messagesRevision + self.persistEligibleJsonOnlyMessages(messagesMap: merged.messagesMap, + identityContext: identityContext) + mergeResult = merged + } + guard committed, + let mergeResult = mergeResult, + let processingRevision = processingRevision else { + result.resolve(with: true) + return + } + + self.processingQueue.async { [weak self] in + guard let self = self else { + result.resolve(with: true) + return + } + let processingResult = appIsReady + ? self.processAndShowMessage(messagesMap: mergeResult.messagesMap, + identityContext: identityContext, + processingRevision: processingRevision) + : Fulfill(value: true) + processingResult.onSuccess { [weak self] _ in + guard let self = self else { + result.resolve(with: true) + return + } + self.syncQueue.async { + guard self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider, { + mergeResult.deliveredMessages.forEach { + self.requestHandler?.track(inAppDelivery: $0, + onSuccess: nil, + onFailure: nil) + } + self.finishSync(inboxChanged: mergeResult.inboxChanged) + }) else { + result.resolve(with: true) + return + } + result.resolve(with: true) + } + }.onError { error in + result.reject(with: error) + } + } } - - finishSync(inboxChanged: mergeMessagesResult.inboxChanged) - - return true + + return result } private func finishSync(inboxChanged: Bool) { @@ -294,6 +404,8 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { return messagesMap case .show(message: _, messagesMap: let messagesMap): return messagesMap + case .jsonOnly(message: _, messagesMap: let messagesMap): + return messagesMap } } @@ -307,19 +419,61 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } } - private func processAndShowMessage(messagesMap: OrderedDictionary) { - var processor = MessagesProcessor(inAppDelegate: inAppDelegate, inAppDisplayChecker: self, messagesMap: messagesMap) + private func processAndShowMessage(messagesMap: OrderedDictionary, + identityContext: UserIdentityContext, + processingRevision: UInt64) -> Pending { + var processor = MessagesProcessor(inAppDelegate: inAppDelegate, + inAppDisplayChecker: self, + messagesMap: messagesMap, + currentDate: dateProvider.currentDate, + isContextCurrent: { [weak self] in + self?.canProcessMessages(identityContext: identityContext, + processingRevision: processingRevision) ?? false + }) let messagesProcessorResult = processor.processMessages() - self.messagesMap = getMessagesMap(fromMessagesProcessorResult: messagesProcessorResult) - - if case let .noShow(message, _) = messagesProcessorResult, - let message = message, message.isJsonOnly { - requestHandler?.inAppConsume(message.messageId, - onSuccess: nil, - onFailure: nil) + let updatedMessagesMap = getMessagesMap(fromMessagesProcessorResult: messagesProcessorResult) + var didCommit = false + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + guard processingRevision == self.messagesRevision else { return } + self.messagesMap = updatedMessagesMap + self.messagesIdentityContext = identityContext + didCommit = true + }), didCommit else { + return Fulfill(value: true) } - - showMessage(fromMessagesProcessorResult: messagesProcessorResult) + + if case let .jsonOnly(message, _) = messagesProcessorResult { + return deliverJsonOnlyMessage(message, consumePreviouslyDelivered: true, identityContext: identityContext).flatMap { [weak self] processed in + guard let self = self, + processed || InAppManager.isExpired(message: message, currentDate: self.dateProvider.currentDate) else { + return Fulfill(value: true) + } + var nextMessagesMap = OrderedDictionary() + guard self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider, { + nextMessagesMap = self.messagesMap + }) else { + return Fulfill(value: true) + } + return self.processAndShowMessage(messagesMap: nextMessagesMap, + identityContext: identityContext, + processingRevision: processingRevision) + } + } + + _ = identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider) { + self.showMessage(fromMessagesProcessorResult: messagesProcessorResult) + } + return Fulfill(value: true) + } + + private func canProcessMessages(identityContext: UserIdentityContext, + processingRevision: UInt64) -> Bool { + var canProcess = false + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + canProcess = processingRevision == self.messagesRevision + }) else { return false } + return canProcess } private func showInternal(message: IterableInAppMessage, @@ -372,10 +526,29 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } private func processExistingMessages() { - _ = InAppManager.getAppIsReady(applicationStateProvider: applicationStateProvider, displayer: displayer).map { [weak self] appIsActive in - if appIsActive, let messagesMap = self?.messagesMap { - self?.processAndShowMessage(messagesMap: messagesMap) - self?.persister.persist(messagesMap.values) + _ = InAppManager.getAppIsReady(applicationStateProvider: applicationStateProvider, displayer: displayer).flatMap { [weak self] appIsActive in + guard appIsActive, let self = self else { + return Fulfill(value: true) + } + let identityContext = self.identityCoordinator.capture(identityProvider: self.identityProvider) + var messagesMap = OrderedDictionary() + var processingRevision: UInt64 = 0 + guard self.identityCoordinator.performIfCurrent(identityContext, identityProvider: self.identityProvider, { + messagesMap = self.messagesMap + processingRevision = self.messagesRevision + self.persistEligibleJsonOnlyMessages(messagesMap: messagesMap, + identityContext: identityContext) + }) else { + return Fulfill(value: true) + } + return self.processAndShowMessage(messagesMap: messagesMap, + identityContext: identityContext, + processingRevision: processingRevision).map { [weak self] _ in + guard let self = self else { return true } + _ = self.identityCoordinator.performIfCurrent(identityContext, identityProvider: self.identityProvider) { + self.persister.persist(self.messagesMap.values) + } + return true } } } @@ -506,6 +679,160 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { messagesMap[message.messageId] = message } } + + private func persistEligibleJsonOnlyMessages(messagesMap: OrderedDictionary, + identityContext: UserIdentityContext) { + guard identityContext.identity != nil else { return } + let messages = messagesMap.values.filter { + $0.isJsonOnly && !$0.didProcessTrigger && !$0.consumed && !$0.read && $0.trigger.type == .immediate + } + guard !messages.isEmpty else { return } + jsonOnlyMessageStore.enqueue(messages, identityContext: identityContext) + } + + private func replayUnhandledJsonOnlyMessages() -> Pending { + InAppManager.getApplicationIsActive(applicationStateProvider: applicationStateProvider).flatMap { [weak self] appIsActive in + guard let self = self else { return Fulfill(value: true) } + let identityContext = self.jsonOnlyMessageStore.identityContext + guard appIsActive, identityContext.identity != nil else { + return Fulfill(value: true) + } + + return self.jsonOnlyMessageStore.getMessages(identityContext: identityContext).reduce(Fulfill(value: true) as Pending) { pending, message in + pending.flatMap { [weak self] _ in + self?.deliverJsonOnlyMessage(message, + consumePreviouslyDelivered: false, + identityContext: identityContext) ?? Fulfill(value: true) + } + } + } + } + + private func deliverJsonOnlyMessage(_ message: IterableInAppMessage, + consumePreviouslyDelivered: Bool, + identityContext: UserIdentityContext) -> Pending { + let result = Fulfill() + + guard identityContext.identity != nil else { + if consumePreviouslyDelivered { + deliverJsonOnlyMessageWithoutAvailability(message, result: result) + } else { + result.resolve(with: false) + } + return result + } + + guard jsonOnlyMessageStore.enqueue(message, identityContext: identityContext) else { + result.resolve(with: false) + return result + } + + let deliver = { [weak self] in + guard let self = self else { + result.resolve(with: false) + return + } + + guard self.applicationStateProvider.applicationState == .active, + let delivery = self.jsonOnlyMessageStore.prepareDelivery(for: message, + identityContext: identityContext) else { + result.resolve(with: false) + return + } + + // Customer callbacks run outside the identity section. Revalidate each boundary so an identity switch + // stops later signals and mutation; these checks must not be deduplicated. + if delivery.isInitial { + guard self.identityCoordinator.isCurrent(identityContext, + identityProvider: self.identityProvider) else { + result.resolve(with: false) + return + } + _ = self.inAppDelegate.onNew(message: delivery.message) + guard self.identityCoordinator.isCurrent(identityContext, + identityProvider: self.identityProvider) else { + result.resolve(with: false) + return + } + } + guard self.identityCoordinator.isCurrent(identityContext, + identityProvider: self.identityProvider) else { + result.resolve(with: false) + return + } + self.inAppDelegate.onJsonOnlyMessageAvailable?(message: delivery.message) + guard self.identityCoordinator.isCurrent(identityContext, + identityProvider: self.identityProvider) else { + result.resolve(with: false) + return + } + self.notificationCenter.post(name: .iterableJsonOnlyInAppMessageAvailable, + object: delivery.message, + userInfo: nil) + guard self.identityCoordinator.isCurrent(identityContext, + identityProvider: self.identityProvider) else { + result.resolve(with: false) + return + } + + guard delivery.isInitial || consumePreviouslyDelivered else { + result.resolve(with: true) + return + } + + self.updateQueue.async { [weak self] in + guard let self = self, + self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider, { + self.updateMessageSync(message, didProcessTrigger: true, consumed: true) + self.requestHandler?.inAppConsume(message.messageId, + onSuccess: nil, + onFailure: nil) + }) else { + result.resolve(with: false) + return + } + result.resolve(with: true) + } + } + + if Thread.isMainThread { + deliver() + } else { + DispatchQueue.main.async(execute: deliver) + } + + return result + } + + private func deliverJsonOnlyMessageWithoutAvailability(_ message: IterableInAppMessage, + result: Fulfill) { + let deliver = { [weak self] in + guard let self = self else { + result.resolve(with: false) + return + } + + _ = self.inAppDelegate.onNew(message: message) + self.updateQueue.async { [weak self] in + guard let self = self else { + result.resolve(with: false) + return + } + self.updateMessageSync(message, didProcessTrigger: true, consumed: true) + self.requestHandler?.inAppConsume(message.messageId, + onSuccess: nil, + onFailure: nil) + result.resolve(with: true) + } + } + + if Thread.isMainThread { + deliver() + } else { + DispatchQueue.main.async(execute: deliver) + } + } // From client side private func removePrivate(message: IterableInAppMessage, @@ -515,16 +842,18 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil) { ITBInfo() - updateMessage(message, didProcessTrigger: true, consumed: true) + updateMessage(message, didProcessTrigger: true, consumed: true).onSuccess { [weak self] _ in + guard message.saveToInbox else { return } + self?.callbackQueue.async { [weak self] in + self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) + } + } requestHandler?.inAppConsume(message: message, location: location, source: source, inboxSessionId: inboxSessionId, onSuccess: successHandler, onFailure: failureHandler) - callbackQueue.async { [weak self] in - self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) - } } private static func isExpired(message: IterableInAppMessage, currentDate: Date) -> Bool { @@ -555,7 +884,21 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { return result } } - + + private static func getApplicationIsActive(applicationStateProvider: ApplicationStateProviderProtocol) -> Fulfill { + if Thread.isMainThread { + return Fulfill(value: applicationStateProvider.applicationState == .active) + } else { + let result = Fulfill() + + DispatchQueue.main.async { + result.resolve(with: applicationStateProvider.applicationState == .active) + } + + return result + } + } + private weak var requestHandler: RequestHandlerProtocol? private let deviceMetadata: DeviceMetadata private let fetcher: InAppFetcherProtocol @@ -570,7 +913,12 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { private let notificationCenter: NotificationCenterProtocol private let persister: InAppPersistenceProtocol + private let jsonOnlyMessageStore: JsonOnlyMessageStore + private let identityCoordinator: IdentityCoordinator + private let identityProvider: () -> UserIdentitySnapshot? private var messagesMap = OrderedDictionary() + private var messagesIdentityContext: UserIdentityContext? + private var messagesRevision: UInt64 = 0 private let dateProvider: DateProviderProtocol private var lastDismissedTime: Date? private var lastDisplayTime: Date? @@ -579,6 +927,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { private let scheduleQueue = DispatchQueue(label: "ScheduleQueue") private let callbackQueue = DispatchQueue(label: "CallbackQueue") private let syncQueue = DispatchQueue(label: "SyncQueue") + private let processingQueue = DispatchQueue(label: "InAppProcessingQueue") private var syncResult: Pending? private var lastSyncTime: Date? @@ -625,17 +974,14 @@ extension InAppManager: InAppNotifiable { ITBInfo() updateQueue.async { [weak self] in - if let _ = self?.messagesMap.filter({ $0.key == messageId }).first { - if let messagesMap = self?.messagesMap { - self?.messagesMap.removeValue(forKey: messageId) - self?.persister.persist(messagesMap.values) - } + guard let self = self, let message = self.messagesMap[messageId] else { return } + self.messagesMap.removeValue(forKey: messageId) + self.persister.persist(self.messagesMap.values) + guard message.saveToInbox else { return } + self.callbackQueue.async { [weak self] in + self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) } } - - callbackQueue.async { [weak self] in - self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) - } } func reset() -> Pending { @@ -644,13 +990,17 @@ extension InAppManager: InAppNotifiable { let result = Fulfill() syncQueue.async { [weak self] in - self?.messagesMap.reset() - if let messagesMap = self?.messagesMap { - self?.persister.persist(messagesMap.values) + guard let self = self else { return } + self.identityCoordinator.withCriticalSection { + let identityContext = self.identityCoordinator.capture(identityProvider: self.identityProvider) + self.messagesRevision &+= 1 + self.messagesMap.reset() + self.messagesIdentityContext = identityContext + self.persister.persist(self.messagesMap.values) } - self?.callbackQueue.async { - self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) + self.callbackQueue.async { + self.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) result.resolve(with: true) } } diff --git a/swift-sdk/Internal/in-app/InAppPersistence.swift b/swift-sdk/Internal/in-app/InAppPersistence.swift index 1222cfd7b..ea0781731 100644 --- a/swift-sdk/Internal/in-app/InAppPersistence.swift +++ b/swift-sdk/Internal/in-app/InAppPersistence.swift @@ -388,6 +388,371 @@ protocol InAppPersistenceProtocol { func clear() } +final class JsonOnlyMessageStore { + struct Delivery { + let message: IterableInAppMessage + let isInitial: Bool + } + + init(localStorage: LocalStorageProtocol, + dateProvider: DateProviderProtocol, + identityProvider: @escaping () -> UserIdentitySnapshot?, + identityCoordinator: IdentityCoordinator) { + self.localStorage = localStorage + self.dateProvider = dateProvider + self.identityProvider = identityProvider + self.identityCoordinator = identityCoordinator + } + + var identityContext: UserIdentityContext { + identityCoordinator.capture(identityProvider: identityProvider) + } + + @discardableResult + func enqueue(_ message: IterableInAppMessage, identityContext: UserIdentityContext) -> Bool { + guard let identity = identityContext.identity else { return false } + var result = false + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + result = stateQueue.sync { + guard var state = loadCurrentState(currentIdentity: identity) else { return false } + let currentDate = dateProvider.currentDate + guard !Self.isExpired(message, at: currentDate) else { return false } + if state.entries.contains(where: { $0.message.messageId == message.messageId }) { + return true + } + guard !state.discardedUnacknowledgedMessageIds.contains(message.messageId) else { return false } + guard !isAcknowledgedUnchanged(message, in: state) else { return false } + state.acknowledgements.removeAll { $0.messageId == message.messageId } + state.entries.append(Entry(message: message, + storedAt: currentDate, + didBeginInitialDelivery: false)) + trimEntries(&state) + return persist(state) + } + }) else { return false } + return result + } + + @discardableResult + func enqueue(_ messages: [IterableInAppMessage], identityContext: UserIdentityContext) -> Bool { + guard let identity = identityContext.identity else { return false } + var result = false + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + result = stateQueue.sync { + guard var state = loadCurrentState(currentIdentity: identity) else { return false } + let currentDate = dateProvider.currentDate + var didChange = false + + messages.forEach { message in + guard !Self.isExpired(message, at: currentDate), + !state.entries.contains(where: { $0.message.messageId == message.messageId }), + !state.discardedUnacknowledgedMessageIds.contains(message.messageId), + !isAcknowledgedUnchanged(message, in: state) else { + return + } + state.acknowledgements.removeAll { $0.messageId == message.messageId } + state.entries.append(Entry(message: message, + storedAt: currentDate, + didBeginInitialDelivery: false)) + didChange = true + } + + guard didChange else { return true } + trimEntries(&state) + return persist(state) + } + }) else { return false } + return result + } + + func prepareDelivery(for message: IterableInAppMessage, identityContext: UserIdentityContext) -> Delivery? { + guard let identity = identityContext.identity else { return nil } + var result: Delivery? + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + result = stateQueue.sync { + // Absence can mean retention/capacity discard or identity clearing, so never recreate a missing entry. + guard var state = loadCurrentState(currentIdentity: identity), + let index = state.entries.firstIndex(where: { $0.message.messageId == message.messageId }) else { + return nil + } + let currentDate = dateProvider.currentDate + guard !Self.isExpired(state.entries[index].message, at: currentDate) else { return nil } + let isInitial = !state.entries[index].didBeginInitialDelivery + if isInitial { + state.entries[index].didBeginInitialDelivery = true + guard persist(state) else { return nil } + } + return Delivery(message: state.entries[index].message, isInitial: isInitial) + } + }) else { return nil } + return result + } + + func getMessages() -> [IterableInAppMessage] { + getMessages(identityContext: identityContext) + } + + func getMessages(identityContext: UserIdentityContext) -> [IterableInAppMessage] { + guard let identity = identityContext.identity else { return [] } + var result = [IterableInAppMessage]() + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + result = stateQueue.sync { + loadCurrentState(currentIdentity: identity)?.entries.map(\.message) ?? [] + } + }) else { return [] } + return result + } + + struct AcknowledgementStatus { + let unchangedMessageIds: Set + let changedMessageIds: Set + } + + func acknowledgementStatus(for messages: [IterableInAppMessage], + identityContext: UserIdentityContext) -> AcknowledgementStatus { + guard let identity = identityContext.identity else { + return AcknowledgementStatus(unchangedMessageIds: [], changedMessageIds: []) + } + var result = AcknowledgementStatus(unchangedMessageIds: [], changedMessageIds: []) + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + result = stateQueue.sync { + guard let state = loadCurrentState(currentIdentity: identity) else { return result } + var unchangedMessageIds = Set() + var changedMessageIds = Set() + messages.forEach { message in + guard message.isJsonOnly else { return } + if state.discardedUnacknowledgedMessageIds.contains(message.messageId) { + unchangedMessageIds.insert(message.messageId) + return + } + guard let acknowledgement = state.acknowledgements.last(where: { $0.messageId == message.messageId }) else { + return + } + if let acknowledgedFingerprint = acknowledgement.payloadFingerprint, + let messageFingerprint = Self.payloadFingerprint(for: message), + acknowledgedFingerprint == messageFingerprint { + unchangedMessageIds.insert(message.messageId) + } else { + changedMessageIds.insert(message.messageId) + } + } + return AcknowledgementStatus(unchangedMessageIds: unchangedMessageIds, + changedMessageIds: changedMessageIds) + } + }) else { return AcknowledgementStatus(unchangedMessageIds: [], changedMessageIds: []) } + return result + } + + @discardableResult + func remove(messageId: String) -> Bool { + let context = identityContext + guard let identity = context.identity else { return false } + var result = false + guard identityCoordinator.performIfCurrent(context, identityProvider: identityProvider, { + result = stateQueue.sync { + guard var state = loadCurrentState(currentIdentity: identity), + let index = state.entries.firstIndex(where: { $0.message.messageId == messageId }) else { + return false + } + + let message = state.entries.remove(at: index).message + state.discardedUnacknowledgedMessageIds.removeAll { $0 == messageId } + state.acknowledgements.removeAll { $0.messageId == messageId } + state.acknowledgements.append(Acknowledgement(messageId: messageId, + payloadFingerprint: Self.payloadFingerprint(for: message))) + state.acknowledgements = Array(state.acknowledgements.suffix(Self.maximumAcknowledgementCount)) + return persist(state) + } + }) else { return false } + return result + } + + func clear() { + identityCoordinator.withCriticalSection { + stateQueue.sync { + localStorage.jsonOnlyMessageQueueData = nil + } + } + } + + private struct StoredIdentity: Codable, Equatable { + enum Kind: String, Codable { + case email + case userId + } + + let kind: Kind + let value: String + + init(_ snapshot: UserIdentitySnapshot) { + switch snapshot { + case let .email(email): + kind = .email + value = email + case let .userId(userId): + kind = .userId + value = userId + } + } + } + + private struct Entry: Codable { + let message: IterableInAppMessage + let storedAt: Date + var didBeginInitialDelivery: Bool + } + + private struct Acknowledgement: Codable { + let messageId: String + let payloadFingerprint: Data? + } + + private struct State: Codable { + let identity: StoredIdentity + var entries: [Entry] + var acknowledgements: [Acknowledgement] + var discardedUnacknowledgedMessageIds: [String] + + init(identity: StoredIdentity, + entries: [Entry], + acknowledgements: [Acknowledgement] = [], + discardedUnacknowledgedMessageIds: [String] = []) { + self.identity = identity + self.entries = entries + self.acknowledgements = acknowledgements + self.discardedUnacknowledgedMessageIds = discardedUnacknowledgedMessageIds + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + identity = try container.decode(StoredIdentity.self, forKey: .identity) + entries = try container.decode([Entry].self, forKey: .entries) + acknowledgements = try container.decodeIfPresent([Acknowledgement].self, forKey: .acknowledgements) ?? [] + discardedUnacknowledgedMessageIds = try container.decodeIfPresent([String].self, + forKey: .discardedUnacknowledgedMessageIds) ?? [] + } + } + + private static func isExpired(_ message: IterableInAppMessage, at currentDate: Date) -> Bool { + guard let expiresAt = message.expiresAt else { return false } + return expiresAt <= currentDate + } + + private func loadCurrentState(currentIdentity: UserIdentitySnapshot) -> State? { + let identity = StoredIdentity(currentIdentity) + var state: State + var stateChanged = false + if let data = localStorage.jsonOnlyMessageQueueData { + do { + state = try JSONDecoder().decode(State.self, from: data) + } catch { + ITBError("Unable to decode unhandled JSON-only messages: \(error.localizedDescription)") + state = State(identity: identity, entries: []) + stateChanged = true + } + } else { + state = State(identity: identity, entries: []) + } + + if state.identity != identity { + state = State(identity: identity, entries: []) + stateChanged = true + } + + let currentDate = dateProvider.currentDate + let expiredEntries = state.entries.filter { entry in + if entry.message.expiresAt != nil { + return Self.isExpired(entry.message, at: currentDate) + } + return entry.storedAt.addingTimeInterval(Self.fallbackRetentionPeriod) <= currentDate + } + + if !expiredEntries.isEmpty { + state.discardedUnacknowledgedMessageIds.append(contentsOf: expiredEntries.map { $0.message.messageId }) + state.discardedUnacknowledgedMessageIds = Array(state.discardedUnacknowledgedMessageIds.suffix(Self.maximumDiscardedMessageCount)) + let expiredMessageIds = Set(expiredEntries.map { $0.message.messageId }) + state.entries.removeAll { expiredMessageIds.contains($0.message.messageId) } + stateChanged = true + } + + if stateChanged, !persist(state) { return nil } + return state + } + + private func isAcknowledgedUnchanged(_ message: IterableInAppMessage, in state: State) -> Bool { + guard let messageFingerprint = Self.payloadFingerprint(for: message) else { return false } + return state.acknowledgements.contains { + $0.messageId == message.messageId && $0.payloadFingerprint == messageFingerprint + } + } + + private func trimEntries(_ state: inout State) { + let overflow = state.entries.count - Self.maximumRecordCount + guard overflow > 0 else { return } + state.discardedUnacknowledgedMessageIds.append(contentsOf: state.entries.prefix(overflow).map { $0.message.messageId }) + state.discardedUnacknowledgedMessageIds = Array(state.discardedUnacknowledgedMessageIds.suffix(Self.maximumDiscardedMessageCount)) + state.entries = Array(state.entries.suffix(Self.maximumRecordCount)) + } + + private static func payloadFingerprint(for message: IterableInAppMessage) -> Data? { + guard let customPayload = message.customPayload, + let canonicalPayload = canonicalJson(customPayload) else { return nil } + return canonicalPayload.data(using: .utf8) + } + + // This encoding is persisted acknowledgement state; stability across SDK upgrades is part of its contract. + private static func canonicalJson(_ value: Any) -> String? { + if let dictionary = value as? [AnyHashable: Any] { + var entries = [(String, Any)]() + for (key, value) in dictionary { + guard let key = key as? String else { return nil } + entries.append((key, value)) + } + entries.sort { $0.0 < $1.0 } + var encodedEntries = [String]() + for (key, value) in entries { + guard let encodedKey = canonicalJson(key), + let encodedValue = canonicalJson(value) else { return nil } + encodedEntries.append("\(encodedKey):\(encodedValue)") + } + return "{\(encodedEntries.joined(separator: ","))}" + } + if let array = value as? [Any] { + var encodedValues = [String]() + for value in array { + guard let encodedValue = canonicalJson(value) else { return nil } + encodedValues.append(encodedValue) + } + return "[\(encodedValues.joined(separator: ","))]" + } + guard JSONSerialization.isValidJSONObject([value]), + let data = try? JSONSerialization.data(withJSONObject: [value], options: []), + let encodedArray = String(data: data, encoding: .utf8) else { return nil } + return String(encodedArray.dropFirst().dropLast()) + } + + private func persist(_ state: State) -> Bool { + do { + localStorage.jsonOnlyMessageQueueData = try JSONEncoder().encode(state) + return true + } catch { + ITBError("Unable to persist unhandled JSON-only messages: \(error.localizedDescription)") + return false + } + } + + private static let fallbackRetentionPeriod: TimeInterval = 30 * 24 * 60 * 60 + private static let maximumRecordCount = 100 + private static let maximumAcknowledgementCount = 100 + private static let maximumDiscardedMessageCount = 100 + + private var localStorage: LocalStorageProtocol + private let dateProvider: DateProviderProtocol + private let identityProvider: () -> UserIdentitySnapshot? + private let identityCoordinator: IdentityCoordinator + private let stateQueue = DispatchQueue(label: "JsonOnlyMessageStore") +} + class InAppInMemoryPersister: InAppPersistenceProtocol { func getMessages() -> [IterableInAppMessage] { [] diff --git a/swift-sdk/SDK/IterableAPI.swift b/swift-sdk/SDK/IterableAPI.swift index ab44eb3dd..a349ae01a 100644 --- a/swift-sdk/SDK/IterableAPI.swift +++ b/swift-sdk/SDK/IterableAPI.swift @@ -922,7 +922,7 @@ import UIKit public static func inAppConsume(message: IterableInAppMessage, location: InAppLocation = .inApp) { guard let implementation, implementation.isSDKInitialized() else { return } - implementation.inAppConsume(message: message, location: location) + implementation.inAppManager.remove(message: message, location: location) } /// Consumes the notification and removes it from the list of in-app messages @@ -935,7 +935,27 @@ import UIKit public static func inAppConsume(message: IterableInAppMessage, location: InAppLocation = .inApp, source: InAppDeleteSource) { guard let implementation, implementation.isSDKInitialized() else { return } - implementation.inAppConsume(message: message, location: location, source: source) + implementation.inAppManager.remove(message: message, location: location, source: source) + } + + /// Returns JSON-only messages for the current user that have not been marked as handled. + @objc + public static func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] { + guard let implementation, implementation.isSDKInitialized() else { return [] } + + return implementation.getUnhandledJsonOnlyMessages() + } + + /// Marks a JSON-only message as handled locally. This does not send an `inAppConsume` request. + /// + /// - Parameter messageId: The ID of the message to remove from the unhandled queue. + /// - Returns: `true` when an unhandled message was removed. + @objc(markJsonOnlyMessageHandled:) + @discardableResult + public static func markJsonOnlyMessageHandled(messageId: String) -> Bool { + guard let implementation, implementation.isSDKInitialized() else { return false } + + return implementation.markJsonOnlyMessageHandled(messageId: messageId) } /// Tracks analytics data from a session of using an inbox UI diff --git a/swift-sdk/SDK/IterableConfig.swift b/swift-sdk/SDK/IterableConfig.swift index 7760371e8..6194b64eb 100644 --- a/swift-sdk/SDK/IterableConfig.swift +++ b/swift-sdk/SDK/IterableConfig.swift @@ -68,6 +68,7 @@ public struct IterableAPIMobileFrameworkInfo: Codable { @objc public protocol IterableInAppDelegate: AnyObject { /// This method is called when new in-app message is available. /// The default behavior is to `show` if you don't override this method. + /// For JSON-only messages, this callback is invoked without SDK locks held. A callback selected for a previous user may complete after a concurrent identity switch; the SDK revalidates afterward and stops later delivery steps, state changes, and consumption. /// /// - Parameters: /// - message: `IterableInAppMessage` object containing information regarding in-app to display @@ -75,6 +76,12 @@ public struct IterableAPIMobileFrameworkInfo: Codable { /// - Returns:Return `show` to show the in-app or `skip` to skip this. @objc(onNewMessage:) func onNew(message: IterableInAppMessage) -> InAppShowResponse + + /// Called on the main thread when a JSON-only message is available locally. + /// This may be called more than once until the message is marked as handled. + /// This callback is invoked without SDK locks held. A callback selected for a previous user may complete after a concurrent identity switch; the SDK revalidates afterward and stops later delivery steps, state changes, and consumption. + @objc(onJsonOnlyMessageAvailable:) + optional func onJsonOnlyMessageAvailable(message: IterableInAppMessage) } /// The protocol for adjusting logging @@ -212,4 +219,3 @@ public class IterableConfig: NSObject { /// The type of mobile framework we are using. public var mobileFrameworkInfo: IterableAPIMobileFrameworkInfo? } - diff --git a/swift-sdk/SDK/IterableMessaging.swift b/swift-sdk/SDK/IterableMessaging.swift index 2cd9d51c8..1e20b9c39 100644 --- a/swift-sdk/SDK/IterableMessaging.swift +++ b/swift-sdk/SDK/IterableMessaging.swift @@ -17,6 +17,16 @@ import UIKit public extension Notification.Name { /// This is fired when in app inbox changes. static let iterableInboxChanged = Notification.Name(rawValue: "itbl_inbox_changed") + + /// This is fired when a JSON-only in-app message is available locally. + /// Observers run on the main thread without SDK locks held. An observer selected for a previous user may complete after a concurrent identity switch; the SDK revalidates afterward and stops later delivery steps, state changes, and consumption. + static let iterableJsonOnlyInAppMessageAvailable = Notification.Name(rawValue: "itbl_json_only_in_app_message_available") +} + +public extension IterableAPI { + /// Objective-C name for the JSON-only availability notification. + /// Observers run on the main thread without SDK locks held. An observer selected for a previous user may complete after a concurrent identity switch; the SDK revalidates afterward and stops later delivery steps, state changes, and consumption. + @objc static let jsonOnlyInAppMessageAvailableNotification = Notification.Name.iterableJsonOnlyInAppMessageAvailable } @objcMembers open class DefaultInAppDelegate: IterableInAppDelegate { diff --git a/tests/common/MockInAppDelegate.swift b/tests/common/MockInAppDelegate.swift index fb074ea37..72b7121bf 100644 --- a/tests/common/MockInAppDelegate.swift +++ b/tests/common/MockInAppDelegate.swift @@ -8,6 +8,7 @@ import Foundation class MockInAppDelegate: IterableInAppDelegate { var onNewMessageCallback: ((IterableInAppMessage) -> Void)? + var onJsonOnlyMessageAvailableCallback: ((IterableInAppMessage) -> Void)? init(showInApp: InAppShowResponse = .show) { self.showInApp = showInApp @@ -17,6 +18,10 @@ class MockInAppDelegate: IterableInAppDelegate { onNewMessageCallback?(message) return showInApp } + + func onJsonOnlyMessageAvailable(message: IterableInAppMessage) { + onJsonOnlyMessageAvailableCallback?(message) + } private let showInApp: InAppShowResponse } diff --git a/tests/common/MockInAppPersister.swift b/tests/common/MockInAppPersister.swift index b55c5e82d..6f473fd12 100644 --- a/tests/common/MockInAppPersister.swift +++ b/tests/common/MockInAppPersister.swift @@ -7,6 +7,8 @@ import Foundation @testable import IterableSDK class MockInAppPersister: InAppPersistenceProtocol { + var onPersist: (() -> Void)? + private var messages = [IterableInAppMessage]() func getMessages() -> [IterableInAppMessage] { @@ -15,6 +17,8 @@ class MockInAppPersister: InAppPersistenceProtocol { func persist(_ messages: [IterableInAppMessage]) { self.messages = messages + let callback = onPersist + callback?() } func clear() { diff --git a/tests/common/MockLocalStorage.swift b/tests/common/MockLocalStorage.swift index 4b48fb25b..28231f066 100644 --- a/tests/common/MockLocalStorage.swift +++ b/tests/common/MockLocalStorage.swift @@ -41,6 +41,19 @@ class MockLocalStorage: LocalStorageProtocol { var isNotificationsEnabled: Bool = false var hasStoredNotificationSetting: Bool = false + + var jsonOnlyMessageQueueData: Data? { + get { + onJsonOnlyMessageQueueDataRead?() + return storedJsonOnlyMessageQueueData + } + set { + storedJsonOnlyMessageQueueData = newValue + jsonOnlyMessageQueueDataWriteCount += 1 + } + } + var onJsonOnlyMessageQueueDataRead: (() -> Void)? + private(set) var jsonOnlyMessageQueueDataWriteCount = 0 func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { guard !MockLocalStorage.isExpired(expiration: attributionInfoExpiration, currentDate: currentDate) else { @@ -56,6 +69,7 @@ class MockLocalStorage: LocalStorageProtocol { private var attributionInfo: IterableAttributionInfo? = nil private var attributionInfoExpiration: Date? = nil + private var storedJsonOnlyMessageQueueData: Data? private static func isExpired(expiration: Date?, currentDate: Date) -> Bool { guard let expiration = expiration else { diff --git a/tests/offline-events-tests/TaskRunnerTests.swift b/tests/offline-events-tests/TaskRunnerTests.swift index efae734e6..41b447752 100644 --- a/tests/offline-events-tests/TaskRunnerTests.swift +++ b/tests/offline-events-tests/TaskRunnerTests.swift @@ -1115,7 +1115,17 @@ class TaskRunnerTests: XCTestCase { func testMixedQueueOnlyUnauthenticatedExecuteDuringAuthPause() throws { let jwtErrorData = ["code": "InvalidJwtPayload"].toJsonData() - let networkSession = MockNetworkSession(statusCode: 401, data: jwtErrorData) + let responseLock = NSLock() + var authFailureSent = false + let networkSession = MockNetworkSession(responseCallback: { url in + responseLock.lock() + defer { responseLock.unlock() } + guard url.path.contains(Const.Path.trackEvent), !authFailureSent else { + return nil + } + authFailureSent = true + return MockNetworkSession.MockResponse(statusCode: 401, data: jwtErrorData) + }) let notificationCenter = MockNotificationCenter() @@ -1128,9 +1138,10 @@ class TaskRunnerTests: XCTestCase { let scheduler = IterableTaskScheduler(persistenceContextProvider: persistenceContextProvider, notificationCenter: notificationCenter, healthMonitor: healthMonitor) - let _ = try scheduleSampleTask(scheduler: scheduler) - let _ = try scheduleUnauthenticatedTask(scheduler: scheduler) - let _ = try scheduleUnauthenticatedTask(scheduler: scheduler) + let scheduledAt = Date(timeIntervalSince1970: 0) + let _ = try scheduleSampleTask(scheduler: scheduler, scheduledAt: scheduledAt) + let _ = try scheduleUnauthenticatedTask(scheduler: scheduler, scheduledAt: scheduledAt.addingTimeInterval(1)) + let _ = try scheduleUnauthenticatedTask(scheduler: scheduler, scheduledAt: scheduledAt.addingTimeInterval(2)) // Wait for all 3 tasks to persist let scheduledPredicate = NSPredicate { _, _ in @@ -1145,6 +1156,13 @@ class TaskRunnerTests: XCTestCase { } XCTAssertNotNil(retryRef) + let unauthSuccessExpectation = expectation(description: "unauthenticated tasks processed") + unauthSuccessExpectation.expectedFulfillmentCount = 2 + let successRef = notificationCenter.addCallback(forNotification: .iterableTaskFinishedWithSuccess) { _ in + unauthSuccessExpectation.fulfill() + } + XCTAssertNotNil(successRef) + let taskRunner = IterableTaskRunner(networkSession: networkSession, persistenceContextProvider: persistenceContextProvider, healthMonitor: healthMonitor, @@ -1157,17 +1175,7 @@ class TaskRunnerTests: XCTestCase { wait(for: [retryExpectation], timeout: 5.0) notificationCenter.removeCallbacks(withIds: retryRef.callbackId) - // Fix network so unauthenticated tasks succeed - networkSession.responseCallback = nil - // Wait for the 2 unauthenticated tasks to be processed - let unauthSuccessExpectation = expectation(description: "unauthenticated tasks processed") - unauthSuccessExpectation.expectedFulfillmentCount = 2 - let successRef = notificationCenter.addCallback(forNotification: .iterableTaskFinishedWithSuccess) { _ in - unauthSuccessExpectation.fulfill() - } - XCTAssertNotNil(successRef) - wait(for: [unauthSuccessExpectation], timeout: 10.0) notificationCenter.removeCallbacks(withIds: successRef.callbackId) @@ -1538,7 +1546,8 @@ class TaskRunnerTests: XCTestCase { } private func scheduleSampleTask(scheduler: IterableTaskScheduler, - authToken: String? = nil) throws -> Pending { + authToken: String? = nil, + scheduledAt: Date? = nil) throws -> Pending { let apiKey = "zee-api-key" let eventName = "CustomEvent1" let dataFields = ["var1": "val1", "var2": "val2"] @@ -1553,11 +1562,12 @@ class TaskRunnerTests: XCTestCase { authToken: authToken, deviceMetadata: deviceMetadata, iterableRequest: trackEventRequest) - return scheduler.schedule(apiCallRequest: apiCallRequest) + return scheduler.schedule(apiCallRequest: apiCallRequest, scheduledAt: scheduledAt) } /// Schedules a task with an unauthenticated API path (disableDevice) for bypass testing. - private func scheduleUnauthenticatedTask(scheduler: IterableTaskScheduler) throws -> Pending { + private func scheduleUnauthenticatedTask(scheduler: IterableTaskScheduler, + scheduledAt: Date? = nil) throws -> Pending { let apiKey = "zee-api-key" let iterableRequest = IterableRequest.post(PostRequest(path: Const.Path.disableDevice, args: nil, @@ -1567,7 +1577,7 @@ class TaskRunnerTests: XCTestCase { authToken: nil, deviceMetadata: deviceMetadata, iterableRequest: iterableRequest) - return scheduler.schedule(apiCallRequest: apiCallRequest) + return scheduler.schedule(apiCallRequest: apiCallRequest, scheduledAt: scheduledAt) } private func verifyNoTaskIsExecuted(_ notificationCenter: MockNotificationCenter, forInterval interval: TimeInterval) { diff --git a/tests/unit-tests/InAppMessageProcessorTests.swift b/tests/unit-tests/InAppMessageProcessorTests.swift index fbac26bfd..465987ac1 100644 --- a/tests/unit-tests/InAppMessageProcessorTests.swift +++ b/tests/unit-tests/InAppMessageProcessorTests.swift @@ -38,6 +38,23 @@ class InAppMessageProcessorTests: XCTestCase { messages: newMessages).handle() XCTAssertEqual(result.deliveredMessages.count, 1) } + + func testServerReadStateDoesNotClearLocalConsumeState() { + let messageId = "msg-1" + let localMessage = Self.makeEmptyInboxMessage(messageId) + localMessage.consumed = true + localMessage.didProcessTrigger = true + let serverMessage = Self.makeEmptyInboxMessage(messageId) + serverMessage.read = true + + let result = MessagesObtainedHandler(messagesMap: [messageId: localMessage], + messages: [serverMessage]).handle() + let mergedMessage = result.messagesMap[messageId] + + XCTAssertTrue(mergedMessage?.read == true) + XCTAssertTrue(mergedMessage?.consumed == true) + XCTAssertTrue(mergedMessage?.didProcessTrigger == true) + } private static let emptyInAppContent = IterableHtmlInAppContent(edgeInsets: .zero, html: "") diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index 6c7f8a85a..2b730c0e2 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -1123,8 +1123,6 @@ class InAppTests: XCTestCase { } func testInboxChangedIsCalledWhenInAppIsRemovedInServer() { - let expectation1 = expectation(description: "testInboxChangedIsCalledWhenInAppIsRemovedInServer") - let notification = """ { "itbl" : { @@ -1135,27 +1133,89 @@ class InAppTests: XCTestCase { "messageId" : "messageId" } """.toJsonDict() - + + let message = IterableInAppMessage(messageId: "messageId", + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: true) + let mockInAppFetcher = MockInAppFetcher() let mockNotificationCenter = MockNotificationCenter() - let reference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in - expectation1.fulfill() + let config = IterableConfig() + let internalApi = InternalIterableAPI.initializeForTesting(config: config, + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) + + let initialInboxExpectation = expectation(description: "initial inbox load") + let initialReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + initialInboxExpectation.fulfill() + } + mockInAppFetcher.mockMessagesAvailableFromServer(internalApi: internalApi, messages: [message]) + wait(for: [initialInboxExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: initialReference.callbackId) + XCTAssertEqual(internalApi.inAppManager.getInboxMessages().count, 1) + + let removalExpectation = expectation(description: "inbox changed after server removal") + removalExpectation.assertForOverFulfill = true + let removalReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + XCTAssertEqual(internalApi.inAppManager.getInboxMessages().count, 0) + removalExpectation.fulfill() } + let appIntegrationInternal = InternalIterableAppIntegration(tracker: internalApi, + urlDelegate: config.urlDelegate, + customActionDelegate: config.customActionDelegate, + urlOpener: MockUrlOpener(), + inAppNotifiable: internalApi.inAppManager, + embeddedNotifiable: internalApi.embeddedManager) - XCTAssertNotNil(reference) + appIntegrationInternal.application(MockApplicationStateProvider(applicationState: .background), didReceiveRemoteNotification: notification, fetchCompletionHandler: nil) + wait(for: [removalExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: removalReference.callbackId) + } + + func testInboxChangedIsNotCalledWhenNonInboxMessageIsRemovedInServer() { + let notification = """ + { + "itbl" : { + "messageId" : "background_notification", + "isGhostPush" : true + }, + "notificationType" : "InAppRemove", + "messageId" : "messageId" + } + """.toJsonDict() + + let message = IterableInAppMessage(messageId: "messageId", + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: false) + let mockInAppFetcher = MockInAppFetcher(messages: [message]) + let mockNotificationCenter = MockNotificationCenter() let config = IterableConfig() - let internalApi = InternalIterableAPI.initializeForTesting(config: config, notificationCenter: mockNotificationCenter) - + let internalApi = InternalIterableAPI.initializeForTesting(config: config, + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) + XCTAssertEqual(internalApi.inAppManager.getMessages().count, 1) + + let notificationExpectation = expectation(description: "no inbox change for non-inbox server removal") + notificationExpectation.isInverted = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + notificationExpectation.fulfill() + } let appIntegrationInternal = InternalIterableAppIntegration(tracker: internalApi, urlDelegate: config.urlDelegate, customActionDelegate: config.customActionDelegate, urlOpener: MockUrlOpener(), inAppNotifiable: internalApi.inAppManager, embeddedNotifiable: internalApi.embeddedManager) - + appIntegrationInternal.application(MockApplicationStateProvider(applicationState: .background), didReceiveRemoteNotification: notification, fetchCompletionHandler: nil) - - wait(for: [expectation1], timeout: testExpectationTimeout) + + wait(for: [notificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(internalApi.inAppManager.getMessages().count, 0) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) } func testSyncIsCalledOnLogin() { @@ -1901,6 +1961,1288 @@ class InAppTests: XCTestCase { } +private final class LegacySwiftInAppDelegate: NSObject, IterableInAppDelegate { + func onNew(message _: IterableInAppMessage) -> InAppShowResponse { + .show + } +} + +private final class CallbackInAppDisplayDelegate: NSObject, IterableInAppDisplayDelegate { + var callback: ((IterableInAppMessage) -> Bool)? + + func isAutoDisplayPaused(for message: IterableInAppMessage) -> Bool { + callback?(message) ?? false + } +} + +private final class BlockingInAppFetcher: InAppFetcherProtocol { + func blockNextFetch(with messages: [IterableInAppMessage]) { + blockedMessages = messages + } + + func fetch() -> Pending<[IterableInAppMessage], Error> { + guard let messages = blockedMessages else { + if didCompleteBlockedFetch { + subsequentFetchStarted.signal() + } + return Fulfill(value: []) + } + + blockedMessages = nil + blockedFetchStarted.signal() + continueBlockedFetch.wait() + didCompleteBlockedFetch = true + return Fulfill(value: messages) + } + + let blockedFetchStarted = DispatchSemaphore(value: 0) + let continueBlockedFetch = DispatchSemaphore(value: 0) + let subsequentFetchStarted = DispatchSemaphore(value: 0) + + private var blockedMessages: [IterableInAppMessage]? + private var didCompleteBlockedFetch = false +} + +private final class MainThreadCheckingApplicationStateProvider: NSObject, ApplicationStateProviderProtocol { + var applicationState: UIApplication.State { + XCTAssertTrue(Thread.isMainThread) + return .active + } +} + +final class JsonOnlyMessageAvailabilityTests: XCTestCase { + override func tearDown() { + IterableAPI.implementation = nil + super.tearDown() + } + + func testStartReadsApplicationStateOnMainThread() { + let startExpectation = expectation(description: "SDK start") + let applicationStateProvider = MainThreadCheckingApplicationStateProvider() + + DispatchQueue.global().async { + IterableAPI.initializeForTesting(applicationStateProvider: applicationStateProvider) + startExpectation.fulfill() + } + + wait(for: [startExpectation], timeout: testExpectationTimeout) + } + + func testAvailabilityPersistsBeforeOrderedMainThreadSignals() { + let onNewExpectation = expectation(description: "legacy onNew") + let availabilityExpectation = expectation(description: "availability delegate") + let notificationExpectation = expectation(description: "availability notification") + let consumeExpectation = expectation(description: "consume request") + let fetcher = MockInAppFetcher() + let notificationCenter = MockNotificationCenter() + let networkSession = MockNetworkSession() + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-1") + var order = [String]() + + delegate.onNewMessageCallback = { deliveredMessage in + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [deliveredMessage.messageId]) + order.append("onNew") + onNewExpectation.fulfill() + } + delegate.onJsonOnlyMessageAvailableCallback = { deliveredMessage in + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(deliveredMessage.messageId, message.messageId) + order.append("delegate") + availabilityExpectation.fulfill() + } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { notification in + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual((notification.object as? IterableInAppMessage)?.messageId, message.messageId) + order.append("notification") + notificationExpectation.fulfill() + } + networkSession.requestCallback = { request in + guard request.url?.path.contains(Const.Path.inAppConsume) == true else { return } + order.append("consume") + consumeExpectation.fulfill() + } + + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + networkSession: networkSession, + notificationCenter: notificationCenter) + fetch([message], with: fetcher, internalAPI: internalAPI) + + wait(for: [onNewExpectation, availabilityExpectation, notificationExpectation, consumeExpectation], + timeout: testExpectationTimeout) + XCTAssertEqual(order, ["onNew", "delegate", "notification", "consume"]) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + func testPerMessageDeliveryAndIndependentAcknowledgement() { + let availabilityExpectation = expectation(description: "per-message availability") + availabilityExpectation.expectedFulfillmentCount = 2 + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let first = makeJsonOnlyMessage(id: "message-1", priorityLevel: 2) + let second = makeJsonOnlyMessage(id: "message-2", priorityLevel: 1) + var deliveredIds = [String]() + + delegate.onJsonOnlyMessageAvailableCallback = { message in + if deliveredIds.isEmpty { + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [first.messageId, second.messageId]) + } + deliveredIds.append(message.messageId) + availabilityExpectation.fulfill() + } + + let internalAPI = initialize(fetcher: fetcher, delegate: delegate) + fetch([first, second], with: fetcher, internalAPI: internalAPI) + + wait(for: [availabilityExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(deliveredIds, [first.messageId, second.messageId]) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [first.messageId, second.messageId]) + XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: first.messageId)) + XCTAssertFalse(IterableAPI.markJsonOnlyMessageHandled(messageId: first.messageId)) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [second.messageId]) + } + + func testExpiredJsonOnlyMessageDoesNotBlockRemainingBatch() { + let availabilityExpectation = expectation(description: "valid JSON availability") + let htmlExpectation = expectation(description: "HTML processed") + let dateProvider = MockDateProvider() + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let expired = makeJsonOnlyMessage(id: "expired", expiresAt: dateProvider.currentDate) + let valid = makeJsonOnlyMessage(id: "valid") + let html = makeHtmlMessage(id: "html", triggerType: .immediate) + + delegate.onNewMessageCallback = { message in + if message.messageId == html.messageId { + htmlExpectation.fulfill() + } + } + delegate.onJsonOnlyMessageAvailableCallback = { message in + XCTAssertEqual(message.messageId, valid.messageId) + availabilityExpectation.fulfill() + } + + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + dateProvider: dateProvider) + fetch([expired, valid, html], with: fetcher, internalAPI: internalAPI) + + wait(for: [availabilityExpectation, htmlExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [valid.messageId]) + XCTAssertTrue(html.didProcessTrigger) + } + + func testJsonOnlyAvailabilityIgnoresAutoDisplayPause() { + let availabilityExpectation = expectation(description: "JSON availability while paused") + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let json = makeJsonOnlyMessage(id: "json") + let html = makeHtmlMessage(id: "html", triggerType: .immediate) + delegate.onJsonOnlyMessageAvailableCallback = { _ in availabilityExpectation.fulfill() } + let internalAPI = initialize(fetcher: fetcher, delegate: delegate) + internalAPI.inAppManager.isAutoDisplayPaused = true + + fetch([json, html], with: fetcher, internalAPI: internalAPI) + + wait(for: [availabilityExpectation], timeout: testExpectationTimeout) + XCTAssertFalse(html.didProcessTrigger) + } + + func testJsonOnlyAvailabilityIgnoresPopupCooldown() { + let firstShowExpectation = expectation(description: "first HTML shown") + let availabilityExpectation = expectation(description: "JSON availability during cooldown") + let dismissalExpectation = expectation(description: "first HTML dismissed") + let dateProvider = MockDateProvider() + let displayer = MockInAppDisplayer() + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let firstHtml = makeHtmlMessage(id: "html-1", triggerType: .immediate) + let secondHtml = makeHtmlMessage(id: "html-2", triggerType: .immediate) + let json = makeJsonOnlyMessage(id: "json") + + displayer.onShow.onSuccess { _ in + displayer.click(url: URL(string: "iterable://dismiss")!) + firstShowExpectation.fulfill() + } + delegate.onJsonOnlyMessageAvailableCallback = { _ in availabilityExpectation.fulfill() } + let internalAPI = initialize(fetcher: fetcher, + displayer: displayer, + delegate: delegate, + dateProvider: dateProvider, + displayInterval: 60) + fetch([firstHtml], with: fetcher, internalAPI: internalAPI) + wait(for: [firstShowExpectation], timeout: testExpectationTimeout) + DispatchQueue.main.async { dismissalExpectation.fulfill() } + wait(for: [dismissalExpectation], timeout: testExpectationTimeout) + + fetch([json, secondHtml], with: fetcher, internalAPI: internalAPI) + + wait(for: [availabilityExpectation], timeout: testExpectationTimeout) + XCTAssertFalse(secondHtml.didProcessTrigger) + } + + func testSlowHtmlCallbackDoesNotBlockReset() { + let callbackStarted = DispatchSemaphore(value: 0) + let releaseCallback = DispatchSemaphore(value: 0) + let resetExpectation = expectation(description: "reset completed") + let fetchExpectation = expectation(description: "fetch completed") + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate(showInApp: .skip) + let message = makeHtmlMessage(id: "html", triggerType: .immediate) + var internalAPI: InternalIterableAPI! + + delegate.onNewMessageCallback = { _ in + callbackStarted.signal() + internalAPI.inAppManager.reset().onSuccess { _ in resetExpectation.fulfill() } + releaseCallback.wait() + } + internalAPI = initialize(fetcher: fetcher, delegate: delegate) + fetcher.add(message: message) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchExpectation.fulfill() } + + XCTAssertEqual(callbackStarted.wait(timeout: .now() + testExpectationTimeout), .success) + wait(for: [resetExpectation], timeout: testExpectationTimeout) + releaseCallback.signal() + wait(for: [fetchExpectation], timeout: testExpectationTimeout) + XCTAssertFalse(internalAPI.inAppManager.getMessages().contains { $0.messageId == message.messageId }) + } + + func testQueuedHtmlProcessingSkipsStaleIdentity() { + let processingStarted = DispatchSemaphore(value: 0) + let releaseProcessing = DispatchSemaphore(value: 0) + let fetchCompleted = expectation(description: "fetch completed") + let noOnNew = expectation(description: "no stale HTML onNew") + noOnNew.isInverted = true + let noDisplay = expectation(description: "no stale HTML display") + noDisplay.isInverted = true + let fetcher = MockInAppFetcher() + let displayer = MockInAppDisplayer() + let delegate = MockInAppDelegate() + let html = makeHtmlMessage(id: "html-a", triggerType: .immediate) + let internalAPI = initialize(fetcher: fetcher, displayer: displayer, delegate: delegate) + let manager = internalAPI.inAppManager as! InAppManager + let processingQueue = Mirror(reflecting: manager).children.first { $0.label == "processingQueue" }?.value as! DispatchQueue + + delegate.onNewMessageCallback = { _ in noOnNew.fulfill() } + displayer.onShow.onSuccess { _ in noDisplay.fulfill() } + processingQueue.async { + processingStarted.signal() + releaseProcessing.wait() + } + XCTAssertEqual(processingStarted.wait(timeout: .now() + testExpectationTimeout), .success) + + fetcher.add(message: html) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchCompleted.fulfill() } + let deadline = Date().addingTimeInterval(testExpectationTimeout) + while !internalAPI.inAppManager.getMessages().contains(where: { $0.messageId == html.messageId }), Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + XCTAssertTrue(internalAPI.inAppManager.getMessages().contains { $0.messageId == html.messageId }) + + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + internalAPI.setUserId("user-b") + releaseProcessing.signal() + + wait(for: [fetchCompleted], timeout: testExpectationTimeout) + wait(for: [noOnNew, noDisplay], timeout: testExpectationTimeoutForInverted) + } + + func testIdentitySwitchInDisplayDelegateStopsOnNew() { + let displayCheckExpectation = expectation(description: "display delegate called") + let noOnNew = expectation(description: "no stale onNew") + noOnNew.isInverted = true + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let displayDelegate = CallbackInAppDisplayDelegate() + let message = makeHtmlMessage(id: "html-a", triggerType: .immediate) + var internalAPI: InternalIterableAPI! + + delegate.onNewMessageCallback = { _ in noOnNew.fulfill() } + displayDelegate.callback = { _ in + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + internalAPI.setUserId("user-b") + displayCheckExpectation.fulfill() + return false + } + internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + displayDelegate: displayDelegate) + + fetch([message], with: fetcher, internalAPI: internalAPI) + + wait(for: [displayCheckExpectation], timeout: testExpectationTimeout) + wait(for: [noOnNew], timeout: testExpectationTimeoutForInverted) + } + + func testIdentitySwitchInOnNewStopsRecursiveDisplayCheck() { + let firstDisplayCheck = expectation(description: "first display check") + let firstOnNew = expectation(description: "first onNew") + let noSecondDisplayCheck = expectation(description: "no stale second display check") + noSecondDisplayCheck.isInverted = true + let noSecondOnNew = expectation(description: "no stale second onNew") + noSecondOnNew.isInverted = true + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate(showInApp: .skip) + let displayDelegate = CallbackInAppDisplayDelegate() + let first = makeHtmlMessage(id: "html-a", triggerType: .immediate) + let second = makeHtmlMessage(id: "html-b", triggerType: .immediate) + var internalAPI: InternalIterableAPI! + + displayDelegate.callback = { message in + if message.messageId == first.messageId { + firstDisplayCheck.fulfill() + } else if message.messageId == second.messageId { + noSecondDisplayCheck.fulfill() + } + return false + } + delegate.onNewMessageCallback = { message in + if message.messageId == first.messageId { + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + internalAPI.setUserId("user-b") + firstOnNew.fulfill() + } else if message.messageId == second.messageId { + noSecondOnNew.fulfill() + } + } + internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + displayDelegate: displayDelegate) + + fetch([first, second], with: fetcher, internalAPI: internalAPI) + + wait(for: [firstDisplayCheck, firstOnNew], timeout: testExpectationTimeout) + wait(for: [noSecondDisplayCheck, noSecondOnNew], timeout: testExpectationTimeoutForInverted) + } + + func testIdentitySwitchInOnNewStopsLaterDeliverySteps() { + assertIdentitySwitchDuringDelivery(at: .onNew) + } + + func testIdentitySwitchInAvailabilityDelegateStopsLaterDeliverySteps() { + assertIdentitySwitchDuringDelivery(at: .delegate) + } + + func testIdentitySwitchInNotificationStopsConsume() { + assertIdentitySwitchDuringDelivery(at: .notification) + } + + func testConcurrentIdentitySwitchDuringOnNewStopsLaterDeliverySteps() { + assertConcurrentIdentitySwitchDuringDelivery(at: .onNew) + } + + func testConcurrentIdentitySwitchDuringAvailabilityDelegateStopsLaterDeliverySteps() { + assertConcurrentIdentitySwitchDuringDelivery(at: .delegate) + } + + func testConcurrentIdentitySwitchDuringNotificationStopsLaterDeliverySteps() { + assertConcurrentIdentitySwitchDuringDelivery(at: .notification) + } + + func testIdentitySwitchBeforeMainDeliveryDoesNotLeakMessage() { + let noOnNewExpectation = expectation(description: "no onNew after identity switch") + noOnNewExpectation.isInverted = true + let noAvailabilityExpectation = expectation(description: "no availability after identity switch") + noAvailabilityExpectation.isInverted = true + let noNotificationExpectation = expectation(description: "no notification after identity switch") + noNotificationExpectation.isInverted = true + let identitySwitchedExpectation = expectation(description: "identity switched") + let fetchCompletedExpectation = expectation(description: "fetch completed") + let fetchStarted = DispatchSemaphore(value: 0) + let continueFetch = DispatchSemaphore(value: 0) + let mainBlocked = DispatchSemaphore(value: 0) + let releaseMain = DispatchSemaphore(value: 0) + let localStorage = MockLocalStorage() + localStorage.email = Self.email + let notificationCenter = MockNotificationCenter() + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-a") + + delegate.onNewMessageCallback = { _ in noOnNewExpectation.fulfill() } + delegate.onJsonOnlyMessageAvailableCallback = { _ in noAvailabilityExpectation.fulfill() } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + noNotificationExpectation.fulfill() + } + let internalAPI = initialize(localStorage: localStorage, + fetcher: fetcher, + delegate: delegate, + notificationCenter: notificationCenter) + fetcher.syncCallback = { [weak fetcher] in + fetcher?.syncCallback = nil + fetchStarted.signal() + continueFetch.wait() + } + fetcher.add(message: message) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchCompletedExpectation.fulfill() } + XCTAssertEqual(fetchStarted.wait(timeout: .now() + testExpectationTimeout), .success) + + DispatchQueue.main.async { + mainBlocked.signal() + _ = releaseMain.wait(timeout: .now() + testExpectationTimeout) + } + DispatchQueue.global().async { + guard mainBlocked.wait(timeout: .now() + testExpectationTimeout) == .success else { + continueFetch.signal() + releaseMain.signal() + return + } + continueFetch.signal() + + let deadline = Date().addingTimeInterval(testExpectationTimeout) + while IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId) != [message.messageId], Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [message.messageId]) + + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + internalAPI.setUserId("user-b") + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + identitySwitchedExpectation.fulfill() + releaseMain.signal() + } + + wait(for: [identitySwitchedExpectation, fetchCompletedExpectation], timeout: testExpectationTimeout) + wait(for: [noOnNewExpectation, noAvailabilityExpectation, noNotificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + func testIdentitySwitchWhileFetchIsInFlightDiscardsResponse() { + let noOnNewExpectation = expectation(description: "no onNew for stale response") + noOnNewExpectation.isInverted = true + let noAvailabilityExpectation = expectation(description: "no availability for stale response") + noAvailabilityExpectation.isInverted = true + let noNotificationExpectation = expectation(description: "no notification for stale response") + noNotificationExpectation.isInverted = true + let settledExpectation = expectation(description: "syncs settled") + let localStorage = MockLocalStorage() + localStorage.email = Self.email + let notificationCenter = MockNotificationCenter() + let fetcher = BlockingInAppFetcher() + let persister = MockInAppPersister() + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-a") + + delegate.onNewMessageCallback = { _ in noOnNewExpectation.fulfill() } + delegate.onJsonOnlyMessageAvailableCallback = { _ in noAvailabilityExpectation.fulfill() } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + noNotificationExpectation.fulfill() + } + let internalAPI = initialize(localStorage: localStorage, + fetcher: fetcher, + persister: persister, + delegate: delegate, + notificationCenter: notificationCenter) + fetcher.blockNextFetch(with: [message]) + _ = internalAPI.inAppManager.scheduleSync() + defer { fetcher.continueBlockedFetch.signal() } + XCTAssertEqual(fetcher.blockedFetchStarted.wait(timeout: .now() + testExpectationTimeout), .success) + + internalAPI.setUserId("user-b") + fetcher.continueBlockedFetch.signal() + XCTAssertEqual(fetcher.subsequentFetchStarted.wait(timeout: .now() + testExpectationTimeout), .success) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in settledExpectation.fulfill() } + + wait(for: [settledExpectation], timeout: testExpectationTimeout) + wait(for: [noOnNewExpectation, noAvailabilityExpectation, noNotificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + XCTAssertFalse(internalAPI.inAppManager.getMessages().contains(where: { $0.messageId == message.messageId })) + XCTAssertFalse(persister.getMessages().contains(where: { $0.messageId == message.messageId })) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + func testIdentitySwitchDuringFetchCommitDoesNotPersistStaleState() { + let commitStarted = DispatchSemaphore(value: 0) + let continueCommit = DispatchSemaphore(value: 0) + let switchCompleted = DispatchSemaphore(value: 0) + let fetchCompleted = expectation(description: "fetch completed") + let localStorage = MockLocalStorage() + localStorage.email = Self.email + let fetcher = MockInAppFetcher() + let persister = MockInAppPersister() + let message = makeJsonOnlyMessage(id: "message-a") + let internalAPI = initialize(localStorage: localStorage, + fetcher: fetcher, + persister: persister) + + localStorage.onJsonOnlyMessageQueueDataRead = { + localStorage.onJsonOnlyMessageQueueDataRead = nil + commitStarted.signal() + continueCommit.wait() + } + fetcher.add(message: message) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchCompleted.fulfill() } + XCTAssertEqual(commitStarted.wait(timeout: .now() + testExpectationTimeout), .success) + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + + DispatchQueue.global().async { + internalAPI.setUserId("user-b") + switchCompleted.signal() + } + XCTAssertEqual(switchCompleted.wait(timeout: .now() + 0.1), .timedOut) + continueCommit.signal() + XCTAssertEqual(switchCompleted.wait(timeout: .now() + testExpectationTimeout), .success) + + wait(for: [fetchCompleted], timeout: testExpectationTimeout) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + XCTAssertFalse(internalAPI.inAppManager.getMessages().contains { $0.messageId == message.messageId }) + XCTAssertFalse(persister.getMessages().contains { $0.messageId == message.messageId }) + } + + func testBackgroundFetchSurvivesRecreationAndDeliversOnForeground() { + let localStorage = MockLocalStorage() + localStorage.email = Self.email + let persister = MockInAppPersister() + let notificationCenter = MockNotificationCenter() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let firstFetcher = MockInAppFetcher() + let message = makeJsonOnlyMessage(id: "message-1") + var firstAPI: InternalIterableAPI? = initialize(localStorage: localStorage, + fetcher: firstFetcher, + persister: persister, + applicationState: applicationState, + notificationCenter: notificationCenter) + + fetch([message], with: firstFetcher, internalAPI: firstAPI!) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [message.messageId]) + + IterableAPI.implementation = nil + firstAPI = nil + + let availabilityExpectation = expectation(description: "foreground replay") + let recreatedDelegate = MockInAppDelegate() + recreatedDelegate.onJsonOnlyMessageAvailableCallback = { deliveredMessage in + XCTAssertEqual(deliveredMessage.messageId, message.messageId) + availabilityExpectation.fulfill() + } + _ = initialize(localStorage: localStorage, + fetcher: MockInAppFetcher(), + persister: persister, + delegate: recreatedDelegate, + applicationState: applicationState, + notificationCenter: notificationCenter) + + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [message.messageId]) + applicationState.applicationState = .active + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [availabilityExpectation], timeout: testExpectationTimeout) + } + + func testForegroundReplayContinuesUntilAcknowledged() { + let replayExpectation = expectation(description: "initial delivery and two replays") + replayExpectation.expectedFulfillmentCount = 3 + let noReplayExpectation = expectation(description: "no replay after acknowledgement") + noReplayExpectation.isInverted = true + let fetcher = MockInAppFetcher() + let notificationCenter = MockNotificationCenter() + let applicationState = MockApplicationStateProvider(applicationState: .active) + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-1") + var availabilityCount = 0 + var onNewCount = 0 + + delegate.onNewMessageCallback = { _ in onNewCount += 1 } + delegate.onJsonOnlyMessageAvailableCallback = { _ in + availabilityCount += 1 + replayExpectation.fulfill() + } + + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + applicationState: applicationState, + notificationCenter: notificationCenter) + fetch([message], with: fetcher, internalAPI: internalAPI) + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [replayExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(onNewCount, 1) + XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: message.messageId)) + + delegate.onJsonOnlyMessageAvailableCallback = { _ in noReplayExpectation.fulfill() } + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + wait(for: [noReplayExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(availabilityCount, 3) + } + + func testDuplicateMessageIdProducesOneUnhandledRecord() { + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + var availabilityCount = 0 + delegate.onJsonOnlyMessageAvailableCallback = { _ in availabilityCount += 1 } + let internalAPI = initialize(fetcher: fetcher, delegate: delegate) + + fetch([makeJsonOnlyMessage(id: "message-1", payloadId: "first")], with: fetcher, internalAPI: internalAPI) + fetch([makeJsonOnlyMessage(id: "message-1", payloadId: "second")], with: fetcher, internalAPI: internalAPI) + + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), ["message-1"]) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().first?.customPayload?["id"] as? String, "first") + XCTAssertEqual(availabilityCount, 1) + } + + func testDuplicateMessageIdKeepsFirstPayloadUntilAcknowledged() { + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) + let store = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) + let identityContext = store.identityContext + let first = makeJsonOnlyMessage(id: "message-1", payloadId: "first") + let second = makeJsonOnlyMessage(id: "message-1", payloadId: "second") + + XCTAssertTrue(store.enqueue([first, second], identityContext: identityContext)) + XCTAssertEqual(store.getMessages().first?.customPayload?["id"] as? String, "first") + + XCTAssertTrue(store.remove(messageId: first.messageId)) + XCTAssertTrue(store.enqueue(second, identityContext: identityContext)) + XCTAssertEqual(store.getMessages().first?.customPayload?["id"] as? String, "second") + } + + func testAcknowledgedMessageIdCanBeReadmittedWithNewPayload() { + let availabilityExpectation = expectation(description: "readmitted availability") + let initialDeliveryTrackExpectation = expectation(description: "initial delivery tracked") + let deliveryTrackExpectation = expectation(description: "readmitted delivery tracked") + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let networkSession = MockNetworkSession(delay: 0.05) + let applicationState = MockApplicationStateProvider(applicationState: .background) + let notificationCenter = MockNotificationCenter() + let first = makeJsonOnlyMessage(id: "message-1", payloadId: "first") + let second = makeJsonOnlyMessage(id: "message-1", payloadId: "second") + var payloadIds = [String]() + delegate.onJsonOnlyMessageAvailableCallback = { message in + payloadIds.append(message.customPayload?["id"] as! String) + availabilityExpectation.fulfill() + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + networkSession: networkSession, + applicationState: applicationState, + notificationCenter: notificationCenter) + + networkSession.requestCallback = { request in + guard request.url?.path.contains(Const.Path.trackInAppDelivery) == true else { return } + initialDeliveryTrackExpectation.fulfill() + } + fetch([first], with: fetcher, internalAPI: internalAPI) + wait(for: [initialDeliveryTrackExpectation], timeout: testExpectationTimeout) + XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: first.messageId)) + networkSession.requestCallback = { request in + guard request.url?.path.contains(Const.Path.trackInAppDelivery) == true else { return } + let body = request.httpBody?.json() as? [String: Any] + XCTAssertEqual(body?[JsonKey.messageId] as? String, second.messageId) + deliveryTrackExpectation.fulfill() + } + fetch([second], with: fetcher, internalAPI: internalAPI) + wait(for: [deliveryTrackExpectation], timeout: testExpectationTimeout) + networkSession.requestCallback = nil + + applicationState.applicationState = .active + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [availabilityExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(payloadIds, ["second"]) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().first?.customPayload?["id"] as? String, "second") + } + + func testAcknowledgedJsonIdDoesNotSuppressSameIdHtmlMessage() { + let onNewExpectation = expectation(description: "HTML onNew") + let displayExpectation = expectation(description: "HTML displayed") + let deliveryTrackExpectation = expectation(description: "HTML delivery tracked") + let inboxChangedExpectation = expectation(description: "inbox changed") + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let displayer = MockInAppDisplayer() + let networkSession = MockNetworkSession() + let notificationCenter = MockNotificationCenter() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let json = makeJsonOnlyMessage(id: "shared", payloadId: "payload") + let html = makeHtmlMessage(id: "shared", + triggerType: .immediate, + saveToInbox: true, + customPayload: ["id": "payload"]) + delegate.onNewMessageCallback = { message in + guard !message.isJsonOnly else { return } + onNewExpectation.fulfill() + } + displayer.onShow.onSuccess { message in + XCTAssertEqual(message.messageId, html.messageId) + displayExpectation.fulfill() + } + let internalAPI = initialize(fetcher: fetcher, + displayer: displayer, + delegate: delegate, + networkSession: networkSession, + applicationState: applicationState, + notificationCenter: notificationCenter) + + fetch([json], with: fetcher, internalAPI: internalAPI) + XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: json.messageId)) + var didObserveDeliveryTrack = false + networkSession.requestCallback = { request in + guard request.url?.path.contains(Const.Path.trackInAppDelivery) == true, + !didObserveDeliveryTrack else { return } + didObserveDeliveryTrack = true + let body = request.httpBody?.json() as? [String: Any] + XCTAssertEqual(body?[JsonKey.messageId] as? String, html.messageId) + deliveryTrackExpectation.fulfill() + } + var didObserveInboxChanged = false + let notificationReference = notificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + guard !didObserveInboxChanged else { return } + didObserveInboxChanged = true + inboxChangedExpectation.fulfill() + } + applicationState.applicationState = .active + fetch([html], with: fetcher, internalAPI: internalAPI) + + wait(for: [onNewExpectation, displayExpectation, deliveryTrackExpectation, inboxChangedExpectation], + timeout: testExpectationTimeout) + XCTAssertTrue(internalAPI.inAppManager.getInboxMessages().contains { + $0.messageId == html.messageId && !$0.isJsonOnly + }) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + func testAcknowledgedJsonTypeRoundTripDoesNotBlockLaterHtml() { + let laterHtmlExpectation = expectation(description: "later HTML processed") + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate(showInApp: .skip) + let applicationState = MockApplicationStateProvider(applicationState: .background) + let json = makeJsonOnlyMessage(id: "shared", payloadId: "payload") + let historicalHtml = makeHtmlMessage(id: "shared", + triggerType: .never, + customPayload: ["id": "payload"]) + let laterHtml = makeHtmlMessage(id: "later", triggerType: .immediate) + delegate.onNewMessageCallback = { message in + if message.messageId == laterHtml.messageId { + laterHtmlExpectation.fulfill() + } + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + applicationState: applicationState) + + fetch([json], with: fetcher, internalAPI: internalAPI) + XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: json.messageId)) + fetch([historicalHtml], with: fetcher, internalAPI: internalAPI) + applicationState.applicationState = .active + + fetch([json, laterHtml], with: fetcher, internalAPI: internalAPI) + + wait(for: [laterHtmlExpectation], timeout: testExpectationTimeout) + XCTAssertTrue(internalAPI.inAppManager.getMessages().contains { $0.messageId == laterHtml.messageId }) + } + + func testTombstonedJsonTypeRoundTripDoesNotBlockLaterHtml() { + let laterHtmlExpectation = expectation(description: "later HTML processed") + let dateProvider = MockDateProvider() + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate(showInApp: .skip) + let applicationState = MockApplicationStateProvider(applicationState: .background) + let json = makeJsonOnlyMessage(id: "shared", payloadId: "payload") + let historicalHtml = makeHtmlMessage(id: "shared", + triggerType: .never, + customPayload: ["id": "payload"]) + let laterHtml = makeHtmlMessage(id: "later", triggerType: .immediate) + delegate.onNewMessageCallback = { message in + if message.messageId == laterHtml.messageId { + laterHtmlExpectation.fulfill() + } + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + applicationState: applicationState, + dateProvider: dateProvider) + + fetch([json], with: fetcher, internalAPI: internalAPI) + dateProvider.currentDate = dateProvider.currentDate.addingTimeInterval(30 * 24 * 60 * 60 + 1) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + fetch([historicalHtml], with: fetcher, internalAPI: internalAPI) + applicationState.applicationState = .active + + fetch([json, laterHtml], with: fetcher, internalAPI: internalAPI) + + wait(for: [laterHtmlExpectation], timeout: testExpectationTimeout) + XCTAssertTrue(internalAPI.inAppManager.getMessages().contains { $0.messageId == laterHtml.messageId }) + } + + func testRetentionDiscardedUnacknowledgedMessageIsNotReadmitted() { + let noAvailability = expectation(description: "no availability after retention discard") + noAvailability.isInverted = true + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let notificationCenter = MockNotificationCenter() + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let first = makeJsonOnlyMessage(id: "message-1", payloadId: "first") + let second = makeJsonOnlyMessage(id: "message-1", payloadId: "second") + delegate.onJsonOnlyMessageAvailableCallback = { _ in noAvailability.fulfill() } + let internalAPI = initialize(localStorage: localStorage, + fetcher: fetcher, + delegate: delegate, + applicationState: applicationState, + notificationCenter: notificationCenter, + dateProvider: dateProvider) + + fetch([first], with: fetcher, internalAPI: internalAPI) + dateProvider.currentDate = dateProvider.currentDate.addingTimeInterval(30 * 24 * 60 * 60 + 1) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + fetch([second], with: fetcher, internalAPI: internalAPI) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + + applicationState.applicationState = .active + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + wait(for: [noAvailability], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + } + + func testIdentitySwitchClearsUnhandledMessages() { + let fetcher = MockInAppFetcher() + let internalAPI = initialize(fetcher: fetcher) + fetch([makeJsonOnlyMessage(id: "message-1")], with: fetcher, internalAPI: internalAPI) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().count, 1) + + fetch([], with: fetcher, internalAPI: internalAPI) + IterableAPI.setUserId("user-b") + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + + IterableAPI.setEmail(Self.email) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + } + + func testConsumeFailureKeepsUnhandledRecord() { + let consumeExpectation = expectation(description: "failed consume request") + let networkSession = MockNetworkSession(statusCode: 500) + var didObserveConsume = false + networkSession.requestCallback = { request in + guard request.url?.path.contains(Const.Path.inAppConsume) == true, !didObserveConsume else { return } + didObserveConsume = true + consumeExpectation.fulfill() + } + let fetcher = MockInAppFetcher() + let internalAPI = initialize(fetcher: fetcher, networkSession: networkSession) + let message = makeJsonOnlyMessage(id: "message-1") + + fetch([message], with: fetcher, internalAPI: internalAPI) + + wait(for: [consumeExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [message.messageId]) + } + + func testIneligibleMessagesDoNotFireAvailability() { + let noAvailabilityExpectation = expectation(description: "no JSON availability") + noAvailabilityExpectation.isInverted = true + let delegate = MockInAppDelegate(showInApp: .skip) + delegate.onJsonOnlyMessageAvailableCallback = { _ in noAvailabilityExpectation.fulfill() } + let fetcher = MockInAppFetcher() + let internalAPI = initialize(fetcher: fetcher, delegate: delegate) + let html = makeHtmlMessage(id: "html", triggerType: .immediate) + let inbox = makeHtmlMessage(id: "inbox", triggerType: .never, saveToInbox: true) + let eventJson = makeJsonOnlyMessage(id: "event-json", triggerType: .event) + + fetch([html, inbox, eventJson], with: fetcher, internalAPI: internalAPI) + + wait(for: [noAvailabilityExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + } + + func testPublicAPIsBeforeInitializationAndLegacySwiftConformance() { + IterableAPI.implementation = nil + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + XCTAssertFalse(IterableAPI.markJsonOnlyMessageHandled(messageId: "message-1")) + + let config = IterableConfig() + config.inAppDelegate = LegacySwiftInAppDelegate() + XCTAssertNotNil(config.inAppDelegate) + } + + func testStoreEnforcesCapacityAndRetention() { + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) + let store = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) + + let identityContext = store.identityContext + let messages = (0...100).map { makeJsonOnlyMessage(id: "message-\($0)") } + XCTAssertTrue(store.enqueue(messages, identityContext: identityContext)) + XCTAssertEqual(localStorage.jsonOnlyMessageQueueDataWriteCount, 1) + + let retainedIds = store.getMessages().map(\.messageId) + XCTAssertEqual(retainedIds.count, 100) + XCTAssertEqual(retainedIds.first, "message-1") + XCTAssertEqual(retainedIds.last, "message-100") + + dateProvider.currentDate = dateProvider.currentDate.addingTimeInterval(30 * 24 * 60 * 60 + 1) + XCTAssertTrue(store.getMessages().isEmpty) + + let expiringLocalStorage = MockLocalStorage() + let expiringDateProvider = MockDateProvider() + let expiringStore = JsonOnlyMessageStore(localStorage: expiringLocalStorage, + dateProvider: expiringDateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) + let expiringIdentityContext = expiringStore.identityContext + expiringStore.enqueue(makeJsonOnlyMessage(id: "expiring", + expiresAt: expiringDateProvider.currentDate.addingTimeInterval(1)), + identityContext: expiringIdentityContext) + expiringDateProvider.currentDate = expiringDateProvider.currentDate.addingTimeInterval(2) + XCTAssertTrue(expiringStore.getMessages().isEmpty) + } + + func testCapacityDiscardedUnacknowledgedMessageIsNotReadmitted() { + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) + let store = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) + let identityContext = store.identityContext + let messages = (0...100).map { makeJsonOnlyMessage(id: "message-\($0)", payloadId: "first") } + XCTAssertTrue(store.enqueue(messages, identityContext: identityContext)) + XCTAssertFalse(store.getMessages().contains { $0.messageId == "message-0" }) + + let changedMessage = makeJsonOnlyMessage(id: "message-0", payloadId: "second") + let status = store.acknowledgementStatus(for: [changedMessage], identityContext: identityContext) + XCTAssertEqual(status.unchangedMessageIds, [changedMessage.messageId]) + XCTAssertTrue(status.changedMessageIds.isEmpty) + XCTAssertFalse(store.enqueue(changedMessage, identityContext: identityContext)) + XCTAssertFalse(store.getMessages().contains { $0.messageId == changedMessage.messageId }) + } + + func testPayloadFingerprintIsStableForNestedKeyOrderAndBooleanType() { + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) + let store = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) + let identityContext = store.identityContext + let first = makeJsonOnlyMessage(id: "message-1", customPayload: [ + "nested": ["b": NSNumber(value: 2), "a": NSNumber(value: true)] + ]) + let reordered = makeJsonOnlyMessage(id: "message-1", customPayload: [ + "nested": ["a": NSNumber(value: true), "b": NSNumber(value: 2)] + ]) + let booleanChangedToNumber = makeJsonOnlyMessage(id: "message-1", customPayload: [ + "nested": ["a": NSNumber(value: 1), "b": NSNumber(value: 2)] + ]) + + XCTAssertTrue(store.enqueue(first, identityContext: identityContext)) + XCTAssertTrue(store.remove(messageId: first.messageId)) + let reorderedStatus = store.acknowledgementStatus(for: [reordered], + identityContext: identityContext) + let changedStatus = store.acknowledgementStatus(for: [booleanChangedToNumber], + identityContext: identityContext) + + XCTAssertEqual(reorderedStatus.unchangedMessageIds, [reordered.messageId]) + XCTAssertTrue(reorderedStatus.changedMessageIds.isEmpty) + XCTAssertTrue(changedStatus.unchangedMessageIds.isEmpty) + XCTAssertEqual(changedStatus.changedMessageIds, [booleanChangedToNumber.messageId]) + } + + func testConcurrentMessageGettersWaitForResetStateCommit() { + let resetPersistStarted = DispatchSemaphore(value: 0) + let releaseResetPersist = DispatchSemaphore(value: 0) + let gettersCompleted = DispatchSemaphore(value: 0) + let resetCompleted = expectation(description: "reset completed") + let fetcher = MockInAppFetcher() + let persister = MockInAppPersister() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let message = makeHtmlMessage(id: "inbox", triggerType: .never, saveToInbox: true) + let internalAPI = initialize(fetcher: fetcher, + persister: persister, + applicationState: applicationState) + fetch([message], with: fetcher, internalAPI: internalAPI) + persister.onPersist = { + persister.onPersist = nil + resetPersistStarted.signal() + releaseResetPersist.wait() + } + + internalAPI.inAppManager.reset().onSuccess { _ in resetCompleted.fulfill() } + XCTAssertEqual(resetPersistStarted.wait(timeout: .now() + testExpectationTimeout), .success) + DispatchQueue.global().async { + XCTAssertTrue(internalAPI.inAppManager.getMessages().isEmpty) + XCTAssertTrue(internalAPI.inAppManager.getInboxMessages().isEmpty) + XCTAssertNil(internalAPI.inAppManager.getMessage(withId: message.messageId)) + gettersCompleted.signal() + } + XCTAssertEqual(gettersCompleted.wait(timeout: .now() + 0.1), .timedOut) + releaseResetPersist.signal() + XCTAssertEqual(gettersCompleted.wait(timeout: .now() + testExpectationTimeout), .success) + wait(for: [resetCompleted], timeout: testExpectationTimeout) + } + + func testStoreDropsAlreadyExpiredMessage() { + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) + let store = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) + let identityContext = store.identityContext + let message = makeJsonOnlyMessage(id: "expired", expiresAt: dateProvider.currentDate) + + XCTAssertFalse(store.enqueue(message, identityContext: identityContext)) + XCTAssertTrue(store.getMessages().isEmpty) + XCTAssertNil(store.prepareDelivery(for: message, identityContext: identityContext)) + XCTAssertTrue(store.getMessages().isEmpty) + } + + func testMessageExpiredBeforeForegroundReplayIsNotSignaled() { + let noDelegateExpectation = expectation(description: "no delegate signal for expired message") + noDelegateExpectation.isInverted = true + let noNotificationExpectation = expectation(description: "no notification for expired message") + noNotificationExpectation.isInverted = true + let dateProvider = MockDateProvider() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let notificationCenter = MockNotificationCenter() + let delegate = MockInAppDelegate() + let fetcher = MockInAppFetcher() + let message = makeJsonOnlyMessage(id: "expiring", + expiresAt: dateProvider.currentDate.addingTimeInterval(1)) + + delegate.onJsonOnlyMessageAvailableCallback = { _ in noDelegateExpectation.fulfill() } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + noNotificationExpectation.fulfill() + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + applicationState: applicationState, + notificationCenter: notificationCenter, + dateProvider: dateProvider) + fetch([message], with: fetcher, internalAPI: internalAPI) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [message.messageId]) + + dateProvider.currentDate = dateProvider.currentDate.addingTimeInterval(2) + applicationState.applicationState = .active + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [noDelegateExpectation, noNotificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + private enum IdentitySwitchStage: Equatable { + case onNew + case delegate + case notification + } + + private func assertConcurrentIdentitySwitchDuringDelivery(at stage: IdentitySwitchStage) { + let surfaceStarted = DispatchSemaphore(value: 0) + let releaseSurface = DispatchSemaphore(value: 0) + let switchCompleted = DispatchSemaphore(value: 0) + let coordinationCompleted = expectation(description: "identity switch coordinated") + let fetchCompleted = expectation(description: "fetch completed") + let noNetworkSideEffect = expectation(description: "no consume or delivery tracking") + noNetworkSideEffect.isInverted = true + let fetcher = MockInAppFetcher() + let notificationCenter = MockNotificationCenter() + let networkSession = MockNetworkSession() + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-a") + var onNewCount = 0 + var delegateCount = 0 + var notificationCount = 0 + + let pauseIfNeeded = { (callbackStage: IdentitySwitchStage) in + guard stage == callbackStage else { return } + surfaceStarted.signal() + releaseSurface.wait() + } + delegate.onNewMessageCallback = { _ in + onNewCount += 1 + pauseIfNeeded(.onNew) + } + delegate.onJsonOnlyMessageAvailableCallback = { _ in + delegateCount += 1 + pauseIfNeeded(.delegate) + } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + notificationCount += 1 + pauseIfNeeded(.notification) + } + networkSession.requestCallback = { request in + if request.url?.path.contains(Const.Path.inAppConsume) == true || + request.url?.path.contains(Const.Path.trackInAppDelivery) == true { + noNetworkSideEffect.fulfill() + } + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + networkSession: networkSession, + notificationCenter: notificationCenter) + DispatchQueue.global().async { + guard surfaceStarted.wait(timeout: .now() + testExpectationTimeout) == .success else { + XCTFail("Delivery surface did not start") + releaseSurface.signal() + coordinationCompleted.fulfill() + return + } + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + DispatchQueue.global().async { + internalAPI.setUserId("user-b") + switchCompleted.signal() + } + XCTAssertEqual(switchCompleted.wait(timeout: .now() + testExpectationTimeout), .success) + releaseSurface.signal() + coordinationCompleted.fulfill() + } + fetcher.add(message: message) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchCompleted.fulfill() } + + wait(for: [coordinationCompleted, fetchCompleted], timeout: testExpectationTimeout) + wait(for: [noNetworkSideEffect], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(onNewCount, 1) + XCTAssertEqual(delegateCount, stage == .onNew ? 0 : 1) + XCTAssertEqual(notificationCount, stage == .notification ? 1 : 0) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + XCTAssertFalse(internalAPI.inAppManager.getMessages().contains { $0.messageId == message.messageId }) + XCTAssertFalse(message.didProcessTrigger) + XCTAssertFalse(message.consumed) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + private func assertIdentitySwitchDuringDelivery(at stage: IdentitySwitchStage) { + let identitySwitchExpectation = expectation(description: "identity switched") + let noConsumeExpectation = expectation(description: "no consume after identity switch") + noConsumeExpectation.isInverted = true + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let notificationCenter = MockNotificationCenter() + let networkSession = MockNetworkSession() + let message = makeJsonOnlyMessage(id: "message-a") + var internalAPI: InternalIterableAPI! + var onNewCount = 0 + var delegateCount = 0 + var notificationCount = 0 + + let switchIdentity = { + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + internalAPI.setUserId("user-b") + identitySwitchExpectation.fulfill() + } + delegate.onNewMessageCallback = { _ in + onNewCount += 1 + if stage == .onNew { switchIdentity() } + } + delegate.onJsonOnlyMessageAvailableCallback = { _ in + delegateCount += 1 + if stage == .delegate { switchIdentity() } + } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + notificationCount += 1 + if stage == .notification { switchIdentity() } + } + networkSession.requestCallback = { request in + if request.url?.path.contains(Const.Path.inAppConsume) == true { + noConsumeExpectation.fulfill() + } + } + internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + networkSession: networkSession, + notificationCenter: notificationCenter) + + fetch([message], with: fetcher, internalAPI: internalAPI) + + wait(for: [identitySwitchExpectation], timeout: testExpectationTimeout) + wait(for: [noConsumeExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(onNewCount, 1) + XCTAssertEqual(delegateCount, stage == .onNew ? 0 : 1) + XCTAssertEqual(notificationCount, stage == .notification ? 1 : 0) + XCTAssertFalse(message.didProcessTrigger) + XCTAssertFalse(message.consumed) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + private func initialize(localStorage: MockLocalStorage = MockLocalStorage(), + fetcher: InAppFetcherProtocol, + persister: InAppPersistenceProtocol = MockInAppPersister(), + displayer: InAppDisplayerProtocol = MockInAppDisplayer(), + delegate: IterableInAppDelegate = MockInAppDelegate(), + displayDelegate: IterableInAppDisplayDelegate? = nil, + networkSession: MockNetworkSession = MockNetworkSession(), + applicationState: MockApplicationStateProvider = MockApplicationStateProvider(applicationState: .active), + notificationCenter: MockNotificationCenter = MockNotificationCenter(), + dateProvider: DateProviderProtocol = SystemDateProvider(), + displayInterval: Double = 0) -> InternalIterableAPI { + if localStorage.email == nil && localStorage.userId == nil { + localStorage.email = Self.email + } + let config = IterableConfig() + config.autoPushRegistration = false + config.inAppDisplayInterval = displayInterval + config.inAppDelegate = delegate + config.inAppDisplayDelegate = displayDelegate + IterableAPI.initializeForTesting(config: config, + dateProvider: dateProvider, + networkSession: networkSession, + localStorage: localStorage, + inAppFetcher: fetcher, + inAppDisplayer: displayer, + inAppPersister: persister, + applicationStateProvider: applicationState, + notificationCenter: notificationCenter) + return IterableAPI.implementation! + } + + private func fetch(_ messages: [IterableInAppMessage], + with fetcher: MockInAppFetcher, + internalAPI: InternalIterableAPI) { + let fetchExpectation = expectation(description: "in-app fetch") + fetcher.mockMessagesAvailableFromServer(internalApi: internalAPI, messages: messages).onSuccess { _ in + fetchExpectation.fulfill() + } + wait(for: [fetchExpectation], timeout: testExpectationTimeout) + } + + private func makeJsonOnlyMessage(id: String, + triggerType: IterableInAppTriggerType = .immediate, + priorityLevel: Double = 0, + expiresAt: Date? = nil, + payloadId: String? = nil, + customPayload: [AnyHashable: Any]? = nil) -> IterableInAppMessage { + IterableInAppMessage(messageId: id, + campaignId: 1, + trigger: .create(withTriggerType: triggerType), + expiresAt: expiresAt, + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + customPayload: customPayload ?? ["id": payloadId ?? id], + priorityLevel: priorityLevel, + jsonOnly: true) + } + + private func makeHtmlMessage(id: String, + triggerType: IterableInAppTriggerType, + saveToInbox: Bool = false, + customPayload: [AnyHashable: Any]? = nil) -> IterableInAppMessage { + IterableInAppMessage(messageId: id, + campaignId: 1, + trigger: .create(withTriggerType: triggerType), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: saveToInbox, + customPayload: customPayload) + } + + private static let email = "json-only@example.com" +} + extension IterableInAppTrigger { override public var description: String { "type: \(type)" @@ -1942,5 +3284,3 @@ extension IterableInAppMessage { pairSeparator: " = ", separator: "\n") } } - - diff --git a/tests/unit-tests/InboxTests.swift b/tests/unit-tests/InboxTests.swift index 79f466015..25eaec802 100644 --- a/tests/unit-tests/InboxTests.swift +++ b/tests/unit-tests/InboxTests.swift @@ -72,14 +72,15 @@ class InboxTests: XCTestCase { } func testSetRead() { - let expectation1 = expectation(description: "testSetRead") let mockInAppFetcher = MockInAppFetcher() + let mockNotificationCenter = MockNotificationCenter() let config = IterableConfig() config.logDelegate = AllLogDelegate() let internalAPI = InternalIterableAPI.initializeForTesting( config: config, - inAppFetcher: mockInAppFetcher + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter ) let payload = """ @@ -104,26 +105,54 @@ class InboxTests: XCTestCase { ] } """.toJsonDict() - - mockInAppFetcher.mockInAppPayloadFromServer(internalApi: internalAPI, payload).onSuccess { _ in - let messages = internalAPI.inAppManager.getInboxMessages() - XCTAssertEqual(messages.count, 2) - - internalAPI.inAppManager.set(read: true, forMessage: messages[1]) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - XCTAssertEqual(messages[0].read, false) - XCTAssertEqual(messages[1].read, true) - - let unreadMessages = internalAPI.inAppManager.getInboxMessages().filter { $0.read == false } - XCTAssertEqual(internalAPI.inAppManager.getUnreadInboxMessagesCount(), 1) - XCTAssertEqual(unreadMessages.count, 1) - XCTAssertEqual(unreadMessages[0].read, false) - expectation1.fulfill() - } + + let initialInboxExpectation = expectation(description: "initial inbox load") + let initialReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + initialInboxExpectation.fulfill() } - - wait(for: [expectation1], timeout: testExpectationTimeout) + mockInAppFetcher.mockInAppPayloadFromServer(internalApi: internalAPI, payload) + wait(for: [initialInboxExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: initialReference.callbackId) + + let messages = internalAPI.inAppManager.getInboxMessages() + XCTAssertEqual(messages.count, 2) + + let readExpectation = expectation(description: "inbox changed after read") + let readReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + XCTAssertTrue(messages[1].read) + readExpectation.fulfill() + } + internalAPI.inAppManager.set(read: true, forMessage: messages[1]) + wait(for: [readExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: readReference.callbackId) + + XCTAssertFalse(messages[0].read) + XCTAssertEqual(internalAPI.inAppManager.getUnreadInboxMessagesCount(), 1) + let unreadMessages = internalAPI.inAppManager.getInboxMessages().filter { !$0.read } + XCTAssertEqual(unreadMessages.count, 1) + XCTAssertFalse(unreadMessages[0].read) + } + + func testSetReadNonInboxMessageDoesNotPostInboxChanged() { + let message = IterableInAppMessage(messageId: "message1", + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: false) + let mockNotificationCenter = MockNotificationCenter() + let internalAPI = InternalIterableAPI.initializeForTesting(inAppFetcher: MockInAppFetcher(messages: [message]), + notificationCenter: mockNotificationCenter) + let notificationExpectation = expectation(description: "no inbox change after non-inbox read") + notificationExpectation.isInverted = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + notificationExpectation.fulfill() + } + + internalAPI.inAppManager.set(read: true, forMessage: message) + + wait(for: [notificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(message.read) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) } func testReceiveReadMessage() { @@ -158,14 +187,15 @@ class InboxTests: XCTestCase { } func testRemove() { - let expectation1 = expectation(description: "testRemove") let mockInAppFetcher = MockInAppFetcher() + let mockNotificationCenter = MockNotificationCenter() let config = IterableConfig() config.logDelegate = AllLogDelegate() let internalAPI = InternalIterableAPI.initializeForTesting( config: config, - inAppFetcher: mockInAppFetcher + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter ) let payload = """ @@ -191,27 +221,58 @@ class InboxTests: XCTestCase { } """.toJsonDict() - mockInAppFetcher.mockInAppPayloadFromServer(internalApi: internalAPI, payload).onSuccess { _ in - let messages = internalAPI.inAppManager.getInboxMessages() - XCTAssertEqual(messages.count, 2) - - let messageToRemove = messages[0] - internalAPI.inAppManager.remove( - message: messageToRemove, - location: .inbox, - source: .inboxSwipe, - successHandler: { _ in }, - failureHandler: { _, _ in } - ) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - let newMessages = internalAPI.inAppManager.getInboxMessages() - XCTAssertEqual(newMessages.count, 1) - expectation1.fulfill() - } + let initialInboxExpectation = expectation(description: "initial inbox load") + let initialReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + initialInboxExpectation.fulfill() } + mockInAppFetcher.mockInAppPayloadFromServer(internalApi: internalAPI, payload) + wait(for: [initialInboxExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: initialReference.callbackId) - wait(for: [expectation1], timeout: testExpectationTimeout) + let messages = internalAPI.inAppManager.getInboxMessages() + XCTAssertEqual(messages.count, 2) + + let removalExpectation = expectation(description: "inbox changed after remove") + removalExpectation.assertForOverFulfill = true + let removalReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + XCTAssertEqual(internalAPI.inAppManager.getInboxMessages().count, 1) + removalExpectation.fulfill() + } + internalAPI.inAppManager.remove( + message: messages[0], + location: .inbox, + source: .inboxSwipe, + successHandler: { _ in }, + failureHandler: { _, _ in } + ) + + wait(for: [removalExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: removalReference.callbackId) + } + + func testRemoveNonInboxMessageDoesNotPostInboxChanged() { + let message = IterableInAppMessage(messageId: "message1", + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: false) + let mockInAppFetcher = MockInAppFetcher(messages: [message]) + let mockNotificationCenter = MockNotificationCenter() + let internalAPI = InternalIterableAPI.initializeForTesting(inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) + XCTAssertEqual(internalAPI.inAppManager.getMessages().count, 1) + + let notificationExpectation = expectation(description: "no inbox change for non-inbox removal") + notificationExpectation.isInverted = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + notificationExpectation.fulfill() + } + + internalAPI.inAppManager.remove(message: message, location: .inApp) + + wait(for: [notificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(internalAPI.inAppManager.getMessages().count, 0) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) } func testShowInboxMessage() { @@ -422,8 +483,8 @@ class InboxTests: XCTestCase { } func testInboxAndInAppCallbacksTogether() { - let expectation1 = expectation(description: "call inbox callback") - expectation1.expectedFulfillmentCount = 2 + let firstInboxObserved = expectation(description: "first inbox callback") + let secondInboxObserved = expectation(description: "second inbox callback") let expectation2 = expectation(description: "call inApp callback") expectation2.expectedFulfillmentCount = 2 let expectation3 = expectation(description: "payload 1 processed") @@ -452,11 +513,12 @@ class InboxTests: XCTestCase { if inboxCallbackCount == 0 { XCTAssertEqual(messages.count, 1, "inboxMessages: \(internalAPI.inAppManager.getInboxMessages())") XCTAssertEqual(messages[0].messageId, "message0") + firstInboxObserved.fulfill() } else { XCTAssertEqual(messages.count, 2) XCTAssertEqual(messages[1].messageId, "message1") + secondInboxObserved.fulfill() } - expectation1.fulfill() inboxCallbackCount += 1 } } @@ -498,7 +560,7 @@ class InboxTests: XCTestCase { mockInAppFetcher.mockInAppPayloadFromServer(internalApi: internalAPI, payload).onSuccess { _ in expectation3.fulfill() } - wait(for: [expectation3], timeout: testExpectationTimeout) + wait(for: [expectation3, firstInboxObserved], timeout: testExpectationTimeout) let payload2 = """ {"inAppMessages": @@ -543,7 +605,7 @@ class InboxTests: XCTestCase { expectation4.fulfill() } - wait(for: [expectation4, expectation1, expectation2], timeout: testExpectationTimeout) + wait(for: [expectation4, secondInboxObserved, expectation2], timeout: testExpectationTimeout) } func testShowNowAndInboxMessage() { diff --git a/tests/unit-tests/IterableAPITests.swift b/tests/unit-tests/IterableAPITests.swift index 376fcbc55..9f4070570 100644 --- a/tests/unit-tests/IterableAPITests.swift +++ b/tests/unit-tests/IterableAPITests.swift @@ -1022,18 +1022,45 @@ class IterableAPITests: XCTestCase { wait(for: [expectation1], timeout: testExpectationTimeout) } - func testInAppConsume() { - let expectation1 = expectation(description: "get in app messages") + func testInAppConsumeRemovesInboxMessageBeforeNotifying() { let messageId = UUID().uuidString - + let message = IterableInAppMessage(messageId: messageId, + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + createdAt: nil, + expiresAt: nil, + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: true, + inboxMetadata: nil, + customPayload: nil) + let mockInAppFetcher = MockInAppFetcher(messages: [message]) + let mockNotificationCenter = MockNotificationCenter() + let initialInboxExpectation = expectation(description: "initial inbox load") + let initialReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + initialInboxExpectation.fulfill() + } let networkSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + localStorage.email = IterableAPITests.email let config = IterableConfig() - let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, config: config, networkSession: networkSession) - internalAPI.email = "user@example.com" - networkSession.callback = { _, response, _ in - guard let (request, body) = TestUtils.matchingRequest(networkSession: networkSession, - response: response, - endPoint: Const.Path.inAppConsume) else { + let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, + config: config, + networkSession: networkSession, + localStorage: localStorage, + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) + let oldImplementation = IterableAPI.implementation + IterableAPI.implementation = internalAPI + defer { IterableAPI.implementation = oldImplementation } + + wait(for: [initialInboxExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: initialReference.callbackId) + XCTAssertEqual(internalAPI.inAppManager.getInboxMessages().count, 1) + + let requestExpectation = expectation(description: "in-app consume request") + requestExpectation.assertForOverFulfill = true + networkSession.requestCallback = { request in + guard request.url?.absoluteString.contains(Const.Path.inAppConsume) == true else { return } TestUtils.validate(request: request, @@ -1041,10 +1068,32 @@ class IterableAPITests: XCTestCase { apiEndPoint: Endpoint.api, path: Const.Path.inAppConsume, queryParams: []) - TestUtils.validateElementPresent(withName: "messageId", andValue: messageId, inDictionary: body) - expectation1.fulfill() + let body = request.httpBody!.json() as! [String: Any] + TestUtils.validateMessageContext(messageId: messageId, + email: IterableAPITests.email, + saveToInbox: true, + silentInbox: true, + location: .inbox, + inBody: body) + requestExpectation.fulfill() } - + + let notificationExpectation = expectation(description: "inbox changed after consume") + notificationExpectation.assertForOverFulfill = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + XCTAssertEqual(internalAPI.inAppManager.getInboxMessages().count, 0) + notificationExpectation.fulfill() + } + + IterableAPI.inAppConsume(message: message, location: .inbox) + + wait(for: [requestExpectation, notificationExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(networkSession.requests.filter { $0.url?.absoluteString.contains(Const.Path.inAppConsume) == true }.count, 1) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + func testInAppConsumeWithSourceRemovesInboxMessageBeforeNotifying() { + let messageId = "message1" let message = IterableInAppMessage(messageId: messageId, campaignId: 1, trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), @@ -1054,50 +1103,108 @@ class IterableAPITests: XCTestCase { saveToInbox: true, inboxMetadata: nil, customPayload: nil) - internalAPI.inAppConsume(message: message) - wait(for: [expectation1], timeout: testExpectationTimeout) - } - - func testTrackInAppConsumeWithSource() { - let messageId = "message1" - let expectation1 = expectation(description: "testTrackInAppConsumeWithSource") - + let mockInAppFetcher = MockInAppFetcher(messages: [message]) + let mockNotificationCenter = MockNotificationCenter() + let initialInboxExpectation = expectation(description: "initial inbox load") + let initialReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + initialInboxExpectation.fulfill() + } let networkSession = MockNetworkSession(statusCode: 200) - let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, networkSession: networkSession) - internalAPI.email = IterableAPITests.email - - networkSession.callback = { _, response, _ in - guard let (request, body) = TestUtils.matchingRequest(networkSession: networkSession, - response: response, - endPoint: Const.Path.inAppConsume) else { + let localStorage = MockLocalStorage() + localStorage.email = IterableAPITests.email + let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, + networkSession: networkSession, + localStorage: localStorage, + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) + let oldImplementation = IterableAPI.implementation + IterableAPI.implementation = internalAPI + defer { IterableAPI.implementation = oldImplementation } + + wait(for: [initialInboxExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: initialReference.callbackId) + XCTAssertEqual(internalAPI.inAppManager.getInboxMessages().count, 1) + + let requestExpectation = expectation(description: "in-app consume request") + requestExpectation.assertForOverFulfill = true + networkSession.requestCallback = { request in + guard request.url?.absoluteString.contains(Const.Path.inAppConsume) == true else { return } - TestUtils.validate(request: request, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.inAppConsume, queryParams: []) + let body = request.httpBody!.json() as! [String: Any] TestUtils.validateMessageContext(messageId: messageId, email: IterableAPITests.email, saveToInbox: true, silentInbox: true, location: .inbox, inBody: body) TestUtils.validateDeviceInfo(inBody: body, withDeviceId: internalAPI.deviceId) TestUtils.validateMatch(keyPath: KeyPath(string: "\(JsonKey.deleteAction)"), value: InAppDeleteSource.deleteButton.jsonValue as! String, inDictionary: body) - - expectation1.fulfill() + requestExpectation.fulfill() } - + + let notificationExpectation = expectation(description: "inbox changed after consume") + notificationExpectation.assertForOverFulfill = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + XCTAssertEqual(internalAPI.inAppManager.getInboxMessages().count, 0) + notificationExpectation.fulfill() + } + + IterableAPI.inAppConsume(message: message, location: .inbox, source: .deleteButton) + + wait(for: [requestExpectation, notificationExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(networkSession.requests.filter { $0.url?.absoluteString.contains(Const.Path.inAppConsume) == true }.count, 1) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + func testInAppConsumeNonInboxMessageDoesNotPostInboxChanged() { + let messageId = "message1" let message = IterableInAppMessage(messageId: messageId, campaignId: 1, trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), - createdAt: nil, - expiresAt: nil, content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), - saveToInbox: true, - inboxMetadata: nil, - customPayload: nil) - - internalAPI.inAppConsume(message: message, location: .inbox, source: .deleteButton) - - wait(for: [expectation1], timeout: testExpectationTimeout) + saveToInbox: false) + let mockInAppFetcher = MockInAppFetcher(messages: [message]) + let mockNotificationCenter = MockNotificationCenter() + let networkSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + localStorage.email = IterableAPITests.email + let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, + networkSession: networkSession, + localStorage: localStorage, + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) + let oldImplementation = IterableAPI.implementation + IterableAPI.implementation = internalAPI + defer { IterableAPI.implementation = oldImplementation } + + XCTAssertEqual(internalAPI.inAppManager.getMessages().count, 1) + + let requestExpectation = expectation(description: "in-app consume request") + networkSession.requestCallback = { request in + guard request.url?.absoluteString.contains(Const.Path.inAppConsume) == true else { return } + let body = request.httpBody!.json() as! [String: Any] + TestUtils.validateMessageContext(messageId: messageId, + email: IterableAPITests.email, + saveToInbox: false, + silentInbox: false, + location: .inApp, + inBody: body) + requestExpectation.fulfill() + } + + let notificationExpectation = expectation(description: "no inbox change for non-inbox consume") + notificationExpectation.isInverted = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + notificationExpectation.fulfill() + } + + IterableAPI.inAppConsume(message: message, location: .inApp) + + wait(for: [requestExpectation, notificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(internalAPI.inAppManager.getMessages().count, 0) + XCTAssertEqual(networkSession.requests.filter { $0.url?.absoluteString.contains(Const.Path.inAppConsume) == true }.count, 1) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) } func testUpdateSubscriptions() { diff --git a/tests/unit-tests/IterableDataRegionObjCTests.m b/tests/unit-tests/IterableDataRegionObjCTests.m index 2684751c0..28eca04ae 100644 --- a/tests/unit-tests/IterableDataRegionObjCTests.m +++ b/tests/unit-tests/IterableDataRegionObjCTests.m @@ -8,6 +8,31 @@ #import @import IterableSDK; +@interface LegacyInAppDelegate : NSObject +@end + +@implementation LegacyInAppDelegate + +- (enum InAppShowResponse)onNewMessage:(IterableInAppMessage * _Nonnull)message { + return InAppShowResponseShow; +} + +@end + +@interface JsonOnlyInAppDelegate : NSObject +@end + +@implementation JsonOnlyInAppDelegate + +- (enum InAppShowResponse)onNewMessage:(IterableInAppMessage * _Nonnull)message { + return InAppShowResponseShow; +} + +- (void)onJsonOnlyMessageAvailable:(IterableInAppMessage * _Nonnull)message { +} + +@end + @interface IterableDataRegionObjCTests : XCTestCase @end @@ -27,4 +52,21 @@ - (void)testIterableDataRegionIsAccessibleFromObjectiveC { XCTAssertEqualObjects(config.dataRegion, @"https://api.eu.iterable.com/api/"); } -@end \ No newline at end of file +- (void)testLegacyInAppDelegateConformanceRemainsValid { + IterableConfig *config = [[IterableConfig alloc] init]; + config.inAppDelegate = [[LegacyInAppDelegate alloc] init]; + XCTAssertNotNil(config.inAppDelegate); +} + +- (void)testJsonOnlyApiSurfaceIsAccessibleFromObjectiveC { + IterableConfig *config = [[IterableConfig alloc] init]; + config.inAppDelegate = [[JsonOnlyInAppDelegate alloc] init]; + XCTAssertNotNil(config.inAppDelegate); + + XCTAssertEqualObjects(IterableAPI.jsonOnlyInAppMessageAvailableNotification, + @"itbl_json_only_in_app_message_available"); + (void)[IterableAPI getUnhandledJsonOnlyMessages]; + XCTAssertFalse([IterableAPI markJsonOnlyMessageHandled:@"missing"]); +} + +@end