From 2635ed892a87dc8d29363263445b6c18bffc7e34 Mon Sep 17 00:00:00 2001 From: Levi Bostian Date: Wed, 22 May 2024 12:21:45 -0500 Subject: [PATCH] chore: create inline UIView that checks if there are inline messages in local queue Part of: https://linear.app/customerio/issue/MBL-310/create-a-uikit-uiview-that-displays-an-inline-in-app-message-sent-from Create a new public UIView that customers can add to their app's UI to display inline Views. This commit does not display messages yet. It only checks if there is an in-app message available to display inline with the equal elementId. Testing: Automated tests added in the commit. Some boilerplate code was added to allow mocking some dependencies and testing functions that were previously not very testable. Reviewer notes: This commit only handles the use case of inline Views being able to display inline messages after a fetch has already completed in the SDK. Future changes will handle when an inline View is visible and a fetch completes. commit-id:60639329 --- Sources/MessagingInApp/Gist/Gist.swift | 37 ++- .../Gist/Managers/MessageQueueManager.swift | 103 ++++--- .../Gist/Managers/Models/Message.swift | 39 ++- .../Views/InAppMessageView.swift | 69 +++++ .../AutoDependencyInjection.generated.swift | 13 + .../AutoMockable.generated.swift | 276 ++++++++++++++++++ .../Extensions/GistExtensions.swift | 20 +- .../Managers/MessageQueueManagerTest.swift | 102 +++++++ .../Gist/Managers/Models/MessageTest.swift | 46 +++ .../Views/InAppMessageViewTest.swift | 46 +++ Tests/Shared/NSCoder.swift | 51 ++++ 11 files changed, 752 insertions(+), 50 deletions(-) create mode 100644 Sources/MessagingInApp/Views/InAppMessageView.swift create mode 100644 Tests/MessagingInApp/Gist/Managers/MessageQueueManagerTest.swift create mode 100644 Tests/MessagingInApp/Gist/Managers/Models/MessageTest.swift create mode 100644 Tests/MessagingInApp/Views/InAppMessageViewTest.swift create mode 100644 Tests/Shared/NSCoder.swift diff --git a/Sources/MessagingInApp/Gist/Gist.swift b/Sources/MessagingInApp/Gist/Gist.swift index 8cfd0ef40..c18d86bcc 100644 --- a/Sources/MessagingInApp/Gist/Gist.swift +++ b/Sources/MessagingInApp/Gist/Gist.swift @@ -1,9 +1,14 @@ +import CioInternalCommon import Foundation import UIKit -public class Gist: GistDelegate { - var messageQueueManager = MessageQueueManager() - var shownMessageQueueIds: Set = [] +protocol GistInstance: AutoMockable { + func showMessage(_ message: Message, position: MessagePosition) -> Bool +} + +public class Gist: GistInstance, GistDelegate { + var messageQueueManager: MessageQueueManager = DIGraphShared.shared.messageQueueManager + var shownModalMessageQueueIds: Set = [] // all modal messages that have been shown in the app already. private var messageManagers: [MessageManager] = [] public var siteId: String = "" public var dataCenter: String = "" @@ -56,7 +61,7 @@ public class Gist: GistDelegate { // MARK: Message Actions - public func showMessage(_ message: Message, position: MessagePosition = .center) -> Bool { + public func showMessage(_ message: Message, position: MessagePosition) -> Bool { if let messageManager = getModalMessageManager() { Logger.instance.info(message: "Message cannot be displayed, \(messageManager.currentMessage.messageId) is being displayed.") } else { @@ -67,6 +72,10 @@ public class Gist: GistDelegate { return false } + public func showMessage(_ message: Message) -> Bool { + showMessage(message, position: .center) + } + public func getMessageView(_ message: Message) -> GistView { let messageManager = createMessageManager(siteId: siteId, message: message) return messageManager.getMessageView() @@ -113,9 +122,16 @@ public class Gist: GistDelegate { } func logMessageView(message: Message) { + // This function body reports metrics and makes sure that messages are not shown 2+ times. + // For inline messages, we have not yet implemented either of these features. + // Therefore, if the message is not a modal, exit early. + guard message.isModalMessage else { + return + } + messageQueueManager.removeMessageFromLocalStore(message: message) if let queueId = message.queueId { - shownMessageQueueIds.insert(queueId) + shownModalMessageQueueIds.insert(queueId) } let userToken = UserManager().getUserToken() LogManager(siteId: siteId, dataCenter: dataCenter) @@ -147,3 +163,14 @@ public class Gist: GistDelegate { messageManagers.removeAll(where: { $0.currentMessage.instanceId == instanceId }) } } + +// Convenient way for other modules to access instance as well as being able to mock instance in tests. +extension DIGraphShared { + var gist: GistInstance { + if let override: GistInstance = getOverriddenInstance() { + return override + } + + return Gist.shared + } +} diff --git a/Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift b/Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift index ffd2735f8..e98f32675 100644 --- a/Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift +++ b/Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift @@ -1,13 +1,36 @@ +import CioInternalCommon import Foundation import UIKit -class MessageQueueManager { - var interval: Double = 600 +protocol MessageQueueManager: AutoMockable { + var interval: Double { get set } + func setup() + // sourcery:Name=setupSkipQueueCheck + // sourcery:DuplicateMethod=setup + func setup(skipQueueCheck: Bool) + func fetchUserMessagesFromLocalStore() + func removeMessageFromLocalStore(message: Message) + func clearUserMessagesFromLocalStore() + func getInlineMessages(forElementId elementId: String) -> [Message] +} + +// sourcery: InjectRegisterShared = "MessageQueueManager" +class MessageQueueManagerImpl: MessageQueueManager { + @Atomic var interval: Double = 600 private var queueTimer: Timer? - // The local message store is used to keep messages that can't be displayed because the route rule doesnt match. - private var localMessageStore: [String: Message] = [:] - func setup(skipQueueCheck: Bool = false) { + // The local message store is used to keep messages that can't be displayed because the route rule doesnt match and inline messages. + @Atomic var localMessageStore: [String: Message] = [:] + + private var gist: GistInstance { + DIGraphShared.shared.gist + } + + func setup() { + setup(skipQueueCheck: false) + } + + func setup(skipQueueCheck: Bool) { queueTimer?.invalidate() queueTimer = nil @@ -30,21 +53,8 @@ class MessageQueueManager { func fetchUserMessagesFromLocalStore() { Logger.instance.info(message: "Checking local store with \(localMessageStore.count) messages") - let sortedMessages = localMessageStore.sorted { - switch ($0.value.priority, $1.value.priority) { - case (let priority0?, let priority1?): - // Both messages have a priority, so we compare them. - return priority0 < priority1 - case (nil, _): - // The first message has no priority, it should be considered greater so that it ends up at the end of the sorted array. - return false - case (_, nil): - // The second message has no priority, the first message should be ordered first. - return true - } - } - sortedMessages.forEach { message in - handleMessage(message: message.value) + localMessageStore.map(\.value).sortByMessagePriority().forEach { message in + handleMessage(message: message) } } @@ -59,7 +69,11 @@ class MessageQueueManager { localMessageStore.removeValue(forKey: queueId) } - private func addMessageToLocalStore(message: Message) { + func getInlineMessages(forElementId elementId: String) -> [Message] { + localMessageStore.filter { $0.value.elementId == elementId }.map(\.value).sortByMessagePriority() + } + + func addMessageToLocalStore(message: Message) { guard let queueId = message.queueId else { return } @@ -80,13 +94,8 @@ class MessageQueueManager { guard let responses else { return } - // To prevent us from showing expired / revoked messages, clear user messages from local queue. - self.clearUserMessagesFromLocalStore() - Logger.instance.info(message: "Gist queue service found \(responses.count) new messages") - for queueMessage in responses { - let message = queueMessage.toMessage() - self.handleMessage(message: message) - } + + self.processFetchedMessages(responses.map { $0.toMessage() }) case .failure(let error): Logger.instance.error(message: "Error fetching messages from Gist queue service. \(error.localizedDescription)") } @@ -99,9 +108,35 @@ class MessageQueueManager { } } + func processFetchedMessages(_ fetchedMessages: [Message]) { + // To prevent us from showing expired / revoked messages, clear user messages from local queue. + clearUserMessagesFromLocalStore() + Logger.instance.info(message: "Gist queue service found \(fetchedMessages.count) new messages") + for message in fetchedMessages { + handleMessage(message: message) + } + } + private func handleMessage(message: Message) { - // Skip shown messages - if let queueId = message.queueId, Gist.shared.shownMessageQueueIds.contains(queueId) { + if message.isInlineMessage { + // Inline Views show inline messages by getting messages stored in the local queue on device. + // So, add the message to the local store and when inline Views are constructed, they will check the store. + + addMessageToLocalStore(message: message) + + // In a future PR, we will want to notify all currently visible inline Views that new messages are available in local store. + // + // At that time, we may decide we do not need these lines anymore. Keeping them in until we implement this notify piece. + // Logger.instance.info(message: "Found a message meant to be shown inline. Element Id \(elementId)") + // Gist.shared.embedMessage(message: message, elementId: elementId) + + return + } + + // Rest of logic of function is for Modal messages + + // Skip showing Modal messages if already shown. + if let queueId = message.queueId, Gist.shared.shownModalMessageQueueIds.contains(queueId) { Logger.instance.info(message: "Message with queueId: \(queueId) already shown, skipping.") return } @@ -123,12 +158,6 @@ class MessageQueueManager { } } - if let elementId = message.gistProperties.elementId { - Logger.instance.info(message: "Embedding message with Element Id \(elementId)") - Gist.shared.embedMessage(message: message, elementId: elementId) - return - } else { - _ = Gist.shared.showMessage(message, position: position) - } + _ = gist.showMessage(message, position: position) } } diff --git a/Sources/MessagingInApp/Gist/Managers/Models/Message.swift b/Sources/MessagingInApp/Gist/Managers/Models/Message.swift index 577807de3..dde94e3a1 100644 --- a/Sources/MessagingInApp/Gist/Managers/Models/Message.swift +++ b/Sources/MessagingInApp/Gist/Managers/Models/Message.swift @@ -18,9 +18,9 @@ public class GistProperties { public class Message { public private(set) var instanceId = UUID().uuidString.lowercased() - public let queueId: String? + public let queueId: String? // uniquely identifies an in-app message in the backend system public let priority: Int? - public let messageId: String + public let messageId: String // the messageId refers to the template used to render. For non-HTML messages, that messageId is something like "welcome-demo" public private(set) var gistProperties: GistProperties var properties = [String: Any]() @@ -92,3 +92,38 @@ public class Message { return engineRoute } } + +// Convenient functions for Inline message feature +extension Message { + var elementId: String? { + gistProperties.elementId + } + + var isInlineMessage: Bool { + elementId != nil + } + + var isModalMessage: Bool { + !isInlineMessage + } +} + +// Messages come with a priority used to determine the order of which messages should show in the app. +// Given a list of Messages that could be displayed, sort them by priority (lower values have higher priority). +extension Array where Element == Message { + func sortByMessagePriority() -> [Message] { + sorted { + switch ($0.priority, $1.priority) { + case (let priority0?, let priority1?): + // Both messages have a priority, so we compare them. + return priority0 < priority1 + case (nil, _): + // The first message has no priority, it should be considered greater so that it ends up at the end of the sorted array. + return false + case (_, nil): + // The second message has no priority, the first message should be ordered first. + return true + } + } + } +} diff --git a/Sources/MessagingInApp/Views/InAppMessageView.swift b/Sources/MessagingInApp/Views/InAppMessageView.swift new file mode 100644 index 000000000..e7f4b6680 --- /dev/null +++ b/Sources/MessagingInApp/Views/InAppMessageView.swift @@ -0,0 +1,69 @@ +import CioInternalCommon +import Foundation +import UIKit + +/** + View that can be added to a customer's app UI to display inline in-app messages. + + Usage: + 1. Create an instance of this View and add it to the customer's app UI. + ``` + // you can construct an instance with code: + let inAppMessageView = InAppMessageView(elementId: "elementId") + view.addSubView(inAppMessageView) + + // Or, if you use Storyboards: + @IBOutlet weak var inAppMessageView: InAppMessageView! + inAppMessageView.elementId = "elementId" + ``` + 2. Position and set size of the View in app's UI. The View will adjust it's height automatically, but all other constraints are the responsibilty of app developer. You can set a height constraint if you want autolayout warnings to go away but know that the View will ignore this set height. + */ +public class InAppMessageView: UIView { + private var localMessageQueue: MessageQueueManager { + DIGraphShared.shared.messageQueueManager + } + + // Can set in the constructor or can set later (like if you use Storyboards) + public var elementId: String? { + didSet { + checkIfMessageAvailableToDisplay() + } + } + + public init(elementId: String) { + super.init(frame: .zero) + self.elementId = elementId + + // Setup the View and display a message, if one available. Since an elementId has been set. + setupView() + checkIfMessageAvailableToDisplay() + } + + // This is called when the View is created from a Storyboard. + required init?(coder: NSCoder) { + super.init(coder: coder) + + setupView() + // An element id will not be set yet. No need to check for messages to display. + } + + private func setupView() {} + + private func checkIfMessageAvailableToDisplay() { + // In a future PR, we will remove the asyncAfter(). this is only for testing in sample apps because when app opens, the local queue is empty. so wait to check messages until first fetch is done. +// DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [self] in + guard let elementId = elementId else { + return + } + + let queueOfMessagesForGivenElementId = localMessageQueue.getInlineMessages(forElementId: elementId) + guard let messageToDisplay = queueOfMessagesForGivenElementId.first else { + return // no messages to display, exit early. In the future we will dismiss the View. + } + + displayInAppMessage(messageToDisplay) + // } + } + + private func displayInAppMessage(_ message: Message) {} +} diff --git a/Sources/MessagingInApp/autogenerated/AutoDependencyInjection.generated.swift b/Sources/MessagingInApp/autogenerated/AutoDependencyInjection.generated.swift index 519fcd833..b97712750 100644 --- a/Sources/MessagingInApp/autogenerated/AutoDependencyInjection.generated.swift +++ b/Sources/MessagingInApp/autogenerated/AutoDependencyInjection.generated.swift @@ -57,6 +57,9 @@ extension DIGraphShared { _ = inAppProvider countDependenciesResolved += 1 + _ = messageQueueManager + countDependenciesResolved += 1 + return countDependenciesResolved } @@ -70,6 +73,16 @@ extension DIGraphShared { private var newInAppProvider: InAppProvider { GistInAppProvider() } + + // MessageQueueManager + var messageQueueManager: MessageQueueManager { + getOverriddenInstance() ?? + newMessageQueueManager + } + + private var newMessageQueueManager: MessageQueueManager { + MessageQueueManagerImpl() + } } // swiftlint:enable all diff --git a/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift b/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift index dad5e65d3..4369abbfd 100644 --- a/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift +++ b/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift @@ -80,6 +80,61 @@ import CioInternalCommon */ +/** + Class to easily create a mocked version of the `GistInstance` class. + This class is equipped with functions and properties ready for you to mock! + + Note: This file is automatically generated. This means the mocks should always be up-to-date and has a consistent API. + See the SDK documentation to learn the basics behind using the mock classes in the SDK. + */ +class GistInstanceMock: GistInstance, Mock { + /// If *any* interactions done on mock. `true` if any method or property getter/setter called. + var mockCalled: Bool = false // + + init() { + Mocks.shared.add(mock: self) + } + + public func resetMock() { + showMessageCallsCount = 0 + showMessageReceivedArguments = nil + showMessageReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + } + + // MARK: - showMessage + + /// Number of times the function was called. + @Atomic private(set) var showMessageCallsCount = 0 + /// `true` if the function was ever called. + var showMessageCalled: Bool { + showMessageCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var showMessageReceivedArguments: (message: Message, position: MessagePosition)? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var showMessageReceivedInvocations: [(message: Message, position: MessagePosition)] = [] + /// Value to return from the mocked function. + var showMessageReturnValue: Bool! + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + The closure has first priority to return a value for the mocked function. If the closure returns `nil`, + then the mock will attempt to return the value for `showMessageReturnValue` + */ + var showMessageClosure: ((Message, MessagePosition) -> Bool)? + + /// Mocked function for `showMessage(_ message: Message, position: MessagePosition)`. Your opportunity to return a mocked value and check result of mock in test code. + func showMessage(_ message: Message, position: MessagePosition) -> Bool { + mockCalled = true + showMessageCallsCount += 1 + showMessageReceivedArguments = (message: message, position: position) + showMessageReceivedInvocations.append((message: message, position: position)) + return showMessageClosure.map { $0(message, position) } ?? showMessageReturnValue + } +} + /** Class to easily create a mocked version of the `InAppEventListener` class. This class is equipped with functions and properties ready for you to mock! @@ -390,6 +445,227 @@ class InAppProviderMock: InAppProvider, Mock { } } +/** + Class to easily create a mocked version of the `MessageQueueManager` class. + This class is equipped with functions and properties ready for you to mock! + + Note: This file is automatically generated. This means the mocks should always be up-to-date and has a consistent API. + See the SDK documentation to learn the basics behind using the mock classes in the SDK. + */ +class MessageQueueManagerMock: MessageQueueManager, Mock { + /// If *any* interactions done on mock. `true` if any method or property getter/setter called. + var mockCalled: Bool = false // + + init() { + Mocks.shared.add(mock: self) + } + + /** + When setter of the property called, the value given to setter is set here. + When the getter of the property called, the value set here will be returned. Your chance to mock the property. + */ + var underlyingInterval: Double! + /// `true` if the getter or setter of property is called at least once. + var intervalCalled: Bool { + intervalGetCalled || intervalSetCalled + } + + /// `true` if the getter called on the property at least once. + var intervalGetCalled: Bool { + intervalGetCallsCount > 0 + } + + var intervalGetCallsCount = 0 + /// `true` if the setter called on the property at least once. + var intervalSetCalled: Bool { + intervalSetCallsCount > 0 + } + + var intervalSetCallsCount = 0 + /// The mocked property with a getter and setter. + var interval: Double { + get { + mockCalled = true + intervalGetCallsCount += 1 + return underlyingInterval + } + set(value) { + mockCalled = true + intervalSetCallsCount += 1 + underlyingInterval = value + } + } + + public func resetMock() { + intervalGetCallsCount = 0 + intervalSetCallsCount = 0 + setupCallsCount = 0 + + mockCalled = false // do last as resetting properties above can make this true + setupSkipQueueCheckReceivedArguments = nil + setupSkipQueueCheckReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + fetchUserMessagesFromLocalStoreCallsCount = 0 + + mockCalled = false // do last as resetting properties above can make this true + removeMessageFromLocalStoreCallsCount = 0 + removeMessageFromLocalStoreReceivedArguments = nil + removeMessageFromLocalStoreReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + clearUserMessagesFromLocalStoreCallsCount = 0 + + mockCalled = false // do last as resetting properties above can make this true + getInlineMessagesCallsCount = 0 + getInlineMessagesReceivedArguments = nil + getInlineMessagesReceivedInvocations = [] + + mockCalled = false // do last as resetting properties above can make this true + } + + // MARK: - setup + + /// Number of times the function was called. + @Atomic private(set) var setupCallsCount = 0 + /// `true` if the function was ever called. + var setupCalled: Bool { + setupCallsCount > 0 + } + + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var setupClosure: (() -> Void)? + + /// Mocked function for `setup()`. Your opportunity to return a mocked value and check result of mock in test code. + func setup() { + mockCalled = true + setupCallsCount += 1 + setupClosure?() + } + + // MARK: - setup + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var setupSkipQueueCheckReceivedArguments: Bool? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var setupSkipQueueCheckReceivedInvocations: [Bool] = [] + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var setupSkipQueueCheckClosure: ((Bool) -> Void)? + + /// Mocked function for `setup(skipQueueCheck: Bool)`. Your opportunity to return a mocked value and check result of mock in test code. + func setup(skipQueueCheck: Bool) { + mockCalled = true + setupCallsCount += 1 + setupSkipQueueCheckReceivedArguments = skipQueueCheck + setupSkipQueueCheckReceivedInvocations.append(skipQueueCheck) + setupSkipQueueCheckClosure?(skipQueueCheck) + } + + // MARK: - fetchUserMessagesFromLocalStore + + /// Number of times the function was called. + @Atomic private(set) var fetchUserMessagesFromLocalStoreCallsCount = 0 + /// `true` if the function was ever called. + var fetchUserMessagesFromLocalStoreCalled: Bool { + fetchUserMessagesFromLocalStoreCallsCount > 0 + } + + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var fetchUserMessagesFromLocalStoreClosure: (() -> Void)? + + /// Mocked function for `fetchUserMessagesFromLocalStore()`. Your opportunity to return a mocked value and check result of mock in test code. + func fetchUserMessagesFromLocalStore() { + mockCalled = true + fetchUserMessagesFromLocalStoreCallsCount += 1 + fetchUserMessagesFromLocalStoreClosure?() + } + + // MARK: - removeMessageFromLocalStore + + /// Number of times the function was called. + @Atomic private(set) var removeMessageFromLocalStoreCallsCount = 0 + /// `true` if the function was ever called. + var removeMessageFromLocalStoreCalled: Bool { + removeMessageFromLocalStoreCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var removeMessageFromLocalStoreReceivedArguments: Message? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var removeMessageFromLocalStoreReceivedInvocations: [Message] = [] + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var removeMessageFromLocalStoreClosure: ((Message) -> Void)? + + /// Mocked function for `removeMessageFromLocalStore(message: Message)`. Your opportunity to return a mocked value and check result of mock in test code. + func removeMessageFromLocalStore(message: Message) { + mockCalled = true + removeMessageFromLocalStoreCallsCount += 1 + removeMessageFromLocalStoreReceivedArguments = message + removeMessageFromLocalStoreReceivedInvocations.append(message) + removeMessageFromLocalStoreClosure?(message) + } + + // MARK: - clearUserMessagesFromLocalStore + + /// Number of times the function was called. + @Atomic private(set) var clearUserMessagesFromLocalStoreCallsCount = 0 + /// `true` if the function was ever called. + var clearUserMessagesFromLocalStoreCalled: Bool { + clearUserMessagesFromLocalStoreCallsCount > 0 + } + + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + */ + var clearUserMessagesFromLocalStoreClosure: (() -> Void)? + + /// Mocked function for `clearUserMessagesFromLocalStore()`. Your opportunity to return a mocked value and check result of mock in test code. + func clearUserMessagesFromLocalStore() { + mockCalled = true + clearUserMessagesFromLocalStoreCallsCount += 1 + clearUserMessagesFromLocalStoreClosure?() + } + + // MARK: - getInlineMessages + + /// Number of times the function was called. + @Atomic private(set) var getInlineMessagesCallsCount = 0 + /// `true` if the function was ever called. + var getInlineMessagesCalled: Bool { + getInlineMessagesCallsCount > 0 + } + + /// The arguments from the *last* time the function was called. + @Atomic private(set) var getInlineMessagesReceivedArguments: String? + /// Arguments from *all* of the times that the function was called. + @Atomic private(set) var getInlineMessagesReceivedInvocations: [String] = [] + /// Value to return from the mocked function. + var getInlineMessagesReturnValue: [Message]! + /** + Set closure to get called when function gets called. Great way to test logic or return a value for the function. + The closure has first priority to return a value for the mocked function. If the closure returns `nil`, + then the mock will attempt to return the value for `getInlineMessagesReturnValue` + */ + var getInlineMessagesClosure: ((String) -> [Message])? + + /// Mocked function for `getInlineMessages(forElementId elementId: String)`. Your opportunity to return a mocked value and check result of mock in test code. + func getInlineMessages(forElementId elementId: String) -> [Message] { + mockCalled = true + getInlineMessagesCallsCount += 1 + getInlineMessagesReceivedArguments = elementId + getInlineMessagesReceivedInvocations.append(elementId) + return getInlineMessagesClosure.map { $0(elementId) } ?? getInlineMessagesReturnValue + } +} + /** Class to easily create a mocked version of the `MessagingInAppInstance` class. This class is equipped with functions and properties ready for you to mock! diff --git a/Tests/MessagingInApp/Extensions/GistExtensions.swift b/Tests/MessagingInApp/Extensions/GistExtensions.swift index 660af5386..d62f2fc8e 100644 --- a/Tests/MessagingInApp/Extensions/GistExtensions.swift +++ b/Tests/MessagingInApp/Extensions/GistExtensions.swift @@ -2,17 +2,25 @@ import Foundation extension Message { - convenience init(messageId: String, campaignId: String) { - let gistProperties = [ - "gist": [ - "campaignId": campaignId - ] + convenience init(messageId: String = .random, campaignId: String = .random, queueId: String = .random, elementId: String? = nil, priority: Int? = nil) { + var gistProperties = [ + "campaignId": campaignId ] - self.init(messageId: messageId, properties: gistProperties) + if let elementId = elementId { + gistProperties["elementId"] = elementId + } + + self.init(queueId: queueId, priority: priority, messageId: messageId, properties: [ + "gist": gistProperties + ]) } static var random: Message { Message(messageId: .random, campaignId: .random) } + + static var randomInline: Message { + Message(messageId: .random, campaignId: .random, elementId: .random) + } } diff --git a/Tests/MessagingInApp/Gist/Managers/MessageQueueManagerTest.swift b/Tests/MessagingInApp/Gist/Managers/MessageQueueManagerTest.swift new file mode 100644 index 000000000..d9fa96b07 --- /dev/null +++ b/Tests/MessagingInApp/Gist/Managers/MessageQueueManagerTest.swift @@ -0,0 +1,102 @@ +@testable import CioInternalCommon +@testable import CioMessagingInApp +import Foundation +import SharedTests +import XCTest + +class MessageQueueManagerTest: UnitTest { + private var manager: MessageQueueManagerImpl! + + private let gistMock = GistInstanceMock() + + override func setUp() { + super.setUp() + + DIGraphShared.shared.override(value: gistMock, forType: GistInstance.self) + + manager = MessageQueueManagerImpl() + } + + // MARK: getInlineMessages + + func test_getInlineMessages_givenEmptyQueue_expectEmptyArray() { + let actualMessages = manager.getInlineMessages(forElementId: .random) + + XCTAssertTrue(actualMessages.isEmpty) + } + + func test_getInlineMessages_givenQueueNotEmpty_expectGetInlineMessagesWithElementId() { + let givenElementId = String.random + let givenModalMessage = Message.random + let givenInlineMessageDifferentElementId = Message.randomInline + let givenInlineMessageSameElementId = Message(messageId: .random, campaignId: .random, elementId: givenElementId) + + manager.localMessageStore = [ + "1": givenModalMessage, + "2": givenInlineMessageDifferentElementId, + "3": givenInlineMessageSameElementId + ] + + let actualMessages = manager.getInlineMessages(forElementId: givenElementId) + + XCTAssertEqual(actualMessages.count, 1) + XCTAssertEqual(actualMessages[0].elementId, givenElementId) + XCTAssertTrue(actualMessages[0].isInlineMessage) + } + + func test_getInlineMessages_expectSortByPriority() { + let givenElementId = String.random + + let givenMessage1 = Message(elementId: givenElementId, priority: 1) + let givenMessage2 = Message(elementId: givenElementId, priority: 0) + let givenMessage3 = Message(elementId: givenElementId, priority: nil) + + manager.localMessageStore = [ + "1": givenMessage1, + "2": givenMessage2, + "3": givenMessage3 + ] + + let actualMessages = manager.getInlineMessages(forElementId: givenElementId) + + XCTAssertEqual(actualMessages.map(\.messageId), [givenMessage2.messageId, givenMessage1.messageId, givenMessage3.messageId]) + } + + // MARK: - processFetchedMessages + + func test_processFetchedMessages_givenEmptyMessages_expectNoProcessingDone() { + manager.processFetchedMessages([]) + + XCTAssertTrue(modalMessageProcessed.isEmpty) + XCTAssertTrue(inlineMessagesProcessed.isEmpty) + } + + func test_processFetchedMessages_givenInlineMessages_expectStoreInLocalQueue() { + let givenInlineMessage = Message.randomInline + + manager.processFetchedMessages([givenInlineMessage]) + + XCTAssertEqual(inlineMessagesProcessed.count, 1) + XCTAssertTrue(modalMessageProcessed.isEmpty) + } + + func test_processFetchedMessages_givenModalMessages_expectForwardRequestToShowMessage() { + let givenModalMessage = Message.random + gistMock.showMessageReturnValue = true + + manager.processFetchedMessages([givenModalMessage]) + + XCTAssertEqual(modalMessageProcessed.count, 1) + XCTAssertTrue(inlineMessagesProcessed.isEmpty) + } +} + +extension MessageQueueManagerTest { + var modalMessageProcessed: [Message] { + gistMock.showMessageReceivedInvocations.map(\.message) + } + + var inlineMessagesProcessed: [Message] { + manager.localMessageStore.values.filter(\.isInlineMessage) + } +} diff --git a/Tests/MessagingInApp/Gist/Managers/Models/MessageTest.swift b/Tests/MessagingInApp/Gist/Managers/Models/MessageTest.swift new file mode 100644 index 000000000..1a05954d2 --- /dev/null +++ b/Tests/MessagingInApp/Gist/Managers/Models/MessageTest.swift @@ -0,0 +1,46 @@ +@testable import CioMessagingInApp +import Foundation +import SharedTests +import XCTest + +class MessageTest: UnitTest { + // MARK: Test getter properties + + func test_elementId_givenInlineMessage_expectGetElementId() { + let givenElementId = String.random + + let message = Message(messageId: .random, campaignId: .random, elementId: givenElementId) + + XCTAssertEqual(message.elementId, givenElementId) + } + + func test_elementId_givenModalMessage_expectNil() { + let message = Message(messageId: .random, campaignId: .random, elementId: nil) + + XCTAssertNil(message.elementId) + } + + func test_isInlineMessage_givenInlineMessage_expectTrue() { + let message = Message(messageId: .random, campaignId: .random, elementId: .random) + + XCTAssertTrue(message.isInlineMessage) + } + + func test_isInlineMessage_givenModalMessage_expectFalse() { + let message = Message(messageId: .random, campaignId: .random, elementId: nil) + + XCTAssertFalse(message.isInlineMessage) + } + + func test_isModalMessage_givenInlineMessage_expectFalse() { + let message = Message(messageId: .random, campaignId: .random, elementId: .random) + + XCTAssertFalse(message.isModalMessage) + } + + func test_isModalMessage_givenModalMessage_expectTrue() { + let message = Message(messageId: .random, campaignId: .random, elementId: nil) + + XCTAssertTrue(message.isModalMessage) + } +} diff --git a/Tests/MessagingInApp/Views/InAppMessageViewTest.swift b/Tests/MessagingInApp/Views/InAppMessageViewTest.swift new file mode 100644 index 000000000..5887e5a2b --- /dev/null +++ b/Tests/MessagingInApp/Views/InAppMessageViewTest.swift @@ -0,0 +1,46 @@ +@testable import CioInternalCommon +@testable import CioMessagingInApp +import Foundation +import SharedTests +import XCTest + +class InAppMessageViewTest: UnitTest { + private let queueMock = MessageQueueManagerMock() + + override func setUp() { + super.setUp() + + DIGraphShared.shared.override(value: queueMock, forType: MessageQueueManager.self) + } + + // MARK: View constructed + + func test_whenViewConstructedUsingStoryboards_expectCheckForMessagesToDisplay() { + let view = InAppMessageView(coder: EmptyNSCoder())! + + // We do not check messages until elementId is set. + XCTAssertFalse(queueMock.mockCalled) + + let givenElementId = String.random + queueMock.getInlineMessagesReturnValue = [] + + view.elementId = givenElementId + + XCTAssertEqual(queueMock.getInlineMessagesCallsCount, 1) + + let actualElementId = queueMock.getInlineMessagesReceivedArguments + XCTAssertEqual(actualElementId, givenElementId) + } + + func test_whenViewConstructedViaCode_expectCheckForMessagesToDisplay() { + let givenElementId = String.random + queueMock.getInlineMessagesReturnValue = [] + + _ = InAppMessageView(elementId: givenElementId) + + XCTAssertEqual(queueMock.getInlineMessagesCallsCount, 1) + + let actualElementId = queueMock.getInlineMessagesReceivedArguments + XCTAssertEqual(actualElementId, givenElementId) + } +} diff --git a/Tests/Shared/NSCoder.swift b/Tests/Shared/NSCoder.swift new file mode 100644 index 000000000..41bc2ca54 --- /dev/null +++ b/Tests/Shared/NSCoder.swift @@ -0,0 +1,51 @@ +import Foundation + +/** + Version of NSCoder you can use in automated tests. + + Convenient when you want to write tests against UIKit UIView classes. + + Usage: + ``` + let viewToTest = InAppMessageView(coder: EmptyNSCoder())! + ``` + */ +public class EmptyNSCoder: NSCoder { + override public var allowsKeyedCoding: Bool { + true + } + + override public func containsValue(forKey key: String) -> Bool { + false + } + + override public func decodeObject(forKey key: String) -> Any? { + nil + } + + override public func encode(_ objv: Any?, forKey key: String) {} + + override public func decodeBool(forKey key: String) -> Bool { + false + } + + override public func decodeInt64(forKey key: String) -> Int64 { + 0 + } + + override public func decodeDouble(forKey key: String) -> Double { + 0.0 + } + + override public func decodeFloat(forKey key: String) -> Float { + 0.0 + } + + override public func decodeInt32(forKey key: String) -> Int32 { + 0 + } + + override public func decodeInteger(forKey key: String) -> Int { + 0 + } +}