diff --git a/AEPOptimize.podspec b/AEPOptimize.podspec index 7788275..4702ef4 100644 --- a/AEPOptimize.podspec +++ b/AEPOptimize.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "AEPOptimize" - s.version = "5.0.1" + s.version = "5.1.0" s.summary = "Experience Platform Optimize extension for Adobe Experience Platform Mobile SDK. Written and maintained by Adobe." s.description = <<-DESC The Experience Platform Optimize extension provides APIs to enable real-time personalization workflows in the Adobe Experience Platform SDKs using Adobe Target or Adobe Journey Optimizer Offer Decisioning. diff --git a/AEPOptimize.xcodeproj/project.pbxproj b/AEPOptimize.xcodeproj/project.pbxproj index 1c996bc..8b84983 100644 --- a/AEPOptimize.xcodeproj/project.pbxproj +++ b/AEPOptimize.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 78AF6DBB284FD9AA0022EB24 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 78AF6DB9284FD9AA0022EB24 /* MainInterface.storyboard */; }; 78AF6DBF284FD9AA0022EB24 /* AEPOptimizeDemoAppExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 78AF6DB5284FD9AA0022EB24 /* AEPOptimizeDemoAppExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 79AF0BBA3B4B3F0D2B46C847 /* Pods_AEPOptimize.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 89FA9F1584C29AFB176C28B9 /* Pods_AEPOptimize.framework */; }; + B6B401C52C8052210082B3FE /* OptimizeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B401C42C8052210082B3FE /* OptimizeError.swift */; }; C8076C4D265EE786006BEC5D /* TargetProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8076C3F265EE768006BEC5D /* TargetProduct.swift */; }; C8076C52265EE789006BEC5D /* TargetOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8076C46265EE776006BEC5D /* TargetOrder.swift */; }; C8076C57265EE78E006BEC5D /* DictionaryRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8076C45265EE776006BEC5D /* DictionaryRowView.swift */; }; @@ -187,6 +188,7 @@ AB699BC71E1531AB6A02B9AD /* Pods-shared-AEPOptimizeDemoAppExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-shared-AEPOptimizeDemoAppExtension.debug.xcconfig"; path = "Target Support Files/Pods-shared-AEPOptimizeDemoAppExtension/Pods-shared-AEPOptimizeDemoAppExtension.debug.xcconfig"; sourceTree = ""; }; AB8BAF0E948D2E2747418C4E /* Pods-FunctionalTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FunctionalTests.debug.xcconfig"; path = "Target Support Files/Pods-FunctionalTests/Pods-FunctionalTests.debug.xcconfig"; sourceTree = ""; }; B588A06BD3941420A1C1CBC5 /* Pods-AEPOptimizeDemoObjC.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AEPOptimizeDemoObjC.debug.xcconfig"; path = "Target Support Files/Pods-AEPOptimizeDemoObjC/Pods-AEPOptimizeDemoObjC.debug.xcconfig"; sourceTree = ""; }; + B6B401C42C8052210082B3FE /* OptimizeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizeError.swift; sourceTree = ""; }; BEA1EA2DAE5E666C7DEEA655 /* Pods-UnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.debug.xcconfig"; path = "Target Support Files/Pods-UnitTests/Pods-UnitTests.debug.xcconfig"; sourceTree = ""; }; C2AB724370848D9EA2ABD4AA /* Pods-AEPOptimizeDemoAppExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AEPOptimizeDemoAppExtension.release.xcconfig"; path = "Target Support Files/Pods-AEPOptimizeDemoAppExtension/Pods-AEPOptimizeDemoAppExtension.release.xcconfig"; sourceTree = ""; }; C592630F611720CFBE4F0009 /* Pods_AEPOptimizeDemoObjC.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AEPOptimizeDemoObjC.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -517,6 +519,7 @@ C88C5F1126152DAB003AE3DE /* DecisionScope.swift */, C88C5EDC2614F640003AE3DE /* Offer.swift */, C8DE6B692684181A0076623F /* Offer+Tracking.swift */, + B6B401C42C8052210082B3FE /* OptimizeError.swift */, C88C5EE02614F693003AE3DE /* OfferType.swift */, C88C5F0D26152CFF003AE3DE /* OptimizeProposition.swift */, C8DE6BEE2686848A0076623F /* Proposition+Tracking.swift */, @@ -1224,6 +1227,7 @@ C88C5F0E26152CFF003AE3DE /* OptimizeProposition.swift in Sources */, C88C5F1226152DAB003AE3DE /* DecisionScope.swift in Sources */, C88C5E952613249C003AE3DE /* OptimizeConstants.swift in Sources */, + B6B401C52C8052210082B3FE /* OptimizeError.swift in Sources */, C88C5EE12614F693003AE3DE /* OfferType.swift in Sources */, C88C5F4126187F0B003AE3DE /* String+Optimize.swift in Sources */, C8276BE4261EC74F00508873 /* Array+Optimize.swift in Sources */, @@ -1652,7 +1656,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 5.0.1; + MARKETING_VERSION = 5.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.AEPOptimize; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -1681,7 +1685,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 5.0.1; + MARKETING_VERSION = 5.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.AEPOptimize; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; diff --git a/Sources/AEPOptimize/Event+Optimize.swift b/Sources/AEPOptimize/Event+Optimize.swift index 85d18ed..3d8cefa 100644 --- a/Sources/AEPOptimize/Event+Optimize.swift +++ b/Sources/AEPOptimize/Event+Optimize.swift @@ -18,6 +18,11 @@ import Foundation extension Event { // MARK: - AEP Response Event handle + /// Verify event type and source for Edge error response event. + var isEdgeErrorResponseEvent: Bool { + type == EventType.edge && source == OptimizeConstants.EventSource.EDGE_ERROR_RESPONSE + } + /// Verify event type and source for Edge personalization:decisions event. var isPersonalizationDecisionResponse: Bool { type == EventType.edge && source == OptimizeConstants.EventSource.EDGE_PERSONALIZATION_DECISIONS @@ -57,15 +62,15 @@ extension Event { return try? JSONDecoder().decode(T.self, from: jsonData) } - /// Creates a response event with specified AEPError type added in the Event data. - /// - Parameter error: type of AEPError + /// Creates a response event with specified AEPOptimizeError type added in the Event data. + /// - Parameter error: type of AEPOptimizeError /// - Returns: error response Event - func createErrorResponseEvent(_ error: AEPError) -> Event { + func createErrorResponseEvent(_ error: AEPOptimizeError) -> Event { createResponseEvent(name: OptimizeConstants.EventNames.OPTIMIZE_RESPONSE, type: EventType.optimize, source: EventSource.responseContent, data: [ - OptimizeConstants.EventDataKeys.RESPONSE_ERROR: error.rawValue + OptimizeConstants.EventDataKeys.RESPONSE_ERROR: error ]) } } diff --git a/Sources/AEPOptimize/Offer.swift b/Sources/AEPOptimize/Offer.swift index 3a9fd2f..90d3f00 100644 --- a/Sources/AEPOptimize/Offer.swift +++ b/Sources/AEPOptimize/Offer.swift @@ -23,7 +23,7 @@ public class Offer: NSObject, Codable { @objc public let etag: String /// Offer priority score - @objc public let score: Int + @objc public let score: Double /// Offer schema string @objc public let schema: String @@ -72,7 +72,7 @@ public class Offer: NSObject, Codable { // Try and decode format, if present. Target response doesn't contain etag, so setting the default value to empty string. etag = try container.decodeIfPresent(String.self, forKey: .etag) ?? "" - score = try container.decodeIfPresent(Int.self, forKey: .score) ?? 0 + score = try container.decodeIfPresent(Double.self, forKey: .score) ?? 0 schema = try container.decode(String.self, forKey: .schema) diff --git a/Sources/AEPOptimize/Optimize+PublicAPI.swift b/Sources/AEPOptimize/Optimize+PublicAPI.swift index 53c4425..eaf4c1d 100644 --- a/Sources/AEPOptimize/Optimize+PublicAPI.swift +++ b/Sources/AEPOptimize/Optimize+PublicAPI.swift @@ -24,6 +24,18 @@ public extension Optimize { /// - Parameter data: Additional free-form data to be sent in the personalization request. @objc(updatePropositions:withXdm:andData:) static func updatePropositions(for decisionScopes: [DecisionScope], withXdm xdm: [String: Any]?, andData data: [String: Any]? = nil) { + updatePropositions(for: decisionScopes, withXdm: xdm, andData: data, nil) + } + + /// This API dispatches an Event for the Edge network extension to fetch decision propositions for the provided decision scopes from the decisioning Services enabled behind Experience Edge. + /// + /// The returned decision propositions are cached in memory in the Optimize SDK extension and can be retrieved using `getPropositions(for:_:)` API. + /// - Parameter decisionScopes: An array of decision scopes. + /// - Parameter xdm: Additional XDM-formatted data to be sent in the personalization request. + /// - Parameter data: Additional free-form data to be sent in the personalization request. + /// - Parameter completion: Optional completion handler invoked with map of successful decision scopes to propositions and errors, if any + @objc(updatePropositions:withXdm:andData:completion:) + static func updatePropositions(for decisionScopes: [DecisionScope], withXdm xdm: [String: Any]?, andData data: [String: Any]? = nil, _ completion: (([DecisionScope: OptimizeProposition]?, Error?) -> Void)? = nil) { let flattenedDecisionScopes = decisionScopes .filter { $0.isValid } .compactMap { $0.asDictionary() } @@ -31,6 +43,8 @@ public extension Optimize { guard !flattenedDecisionScopes.isEmpty else { Log.warning(label: OptimizeConstants.LOG_TAG, "Cannot update propositions, provided decision scopes array is empty or has invalid items.") + let aepOptimizeError = AEPOptimizeError.createAEPOptimizInvalidRequestError() + completion?(nil, aepOptimizeError) return } @@ -53,8 +67,16 @@ public extension Optimize { type: EventType.optimize, source: EventSource.requestContent, data: eventData) - - MobileCore.dispatch(event: event) + MobileCore.dispatch(event: event, timeout: 10) { responseEvent in + guard let responseEvent = responseEvent else { + let timeoutError = AEPOptimizeError.createAEPOptimizeTimeoutError() + completion?(nil, timeoutError) + return + } + let result = responseEvent.data?[OptimizeConstants.EventDataKeys.PROPOSITIONS] as? [DecisionScope: OptimizeProposition] + let error = responseEvent.data?[OptimizeConstants.EventDataKeys.RESPONSE_ERROR] as? AEPOptimizeError + completion?(result, error) + } } /// This API retrieves the previously fetched decisions for the provided decision scopes from the in-memory extension cache. @@ -93,8 +115,8 @@ public extension Optimize { return } - if let error = responseEvent.data?[OptimizeConstants.EventDataKeys.RESPONSE_ERROR] as? AEPError { - completion(nil, error) + if let error = responseEvent.data?[OptimizeConstants.EventDataKeys.RESPONSE_ERROR] as? AEPOptimizeError { + completion(nil, error.aepError) return } diff --git a/Sources/AEPOptimize/Optimize.swift b/Sources/AEPOptimize/Optimize.swift index 43ea518..147f554 100644 --- a/Sources/AEPOptimize/Optimize.swift +++ b/Sources/AEPOptimize/Optimize.swift @@ -32,6 +32,13 @@ public class Optimize: NSObject, Extension { /// Dispatch queue used to protect against simultaneous access of our containers from multiple threads private let queue: DispatchQueue = .init(label: "com.adobe.optimize.containers.queue") + /// a dictionary containing the update event IDs and corresponding errors as received from Edge SDK + private var _updateRequestEventIdsErrors: [String: AEPOptimizeError] = [:] + private var updateRequestEventIdsErrors: [String: AEPOptimizeError] { + get { queue.sync { self._updateRequestEventIdsErrors } } + set { queue.async { self._updateRequestEventIdsErrors = newValue } } + } + /// a dictionary containing the update event IDs (and corresponding requested scopes) for Edge events that haven't yet received an Edge completion response. private var _updateRequestEventIdsInProgress: [String: [DecisionScope]] = [:] private var updateRequestEventIdsInProgress: [String: [DecisionScope]] { @@ -60,6 +67,13 @@ public class Optimize: NSObject, Extension { } #endif + /// Array containing recoverable network error codes being retried by Edge Network Service + private let recoverableNetworkErrorCodes: [Int] = [OptimizeConstants.HTTPResponseCodes.clientTimeout.rawValue, + OptimizeConstants.HTTPResponseCodes.tooManyRequests.rawValue, + OptimizeConstants.HTTPResponseCodes.badGateway.rawValue, + OptimizeConstants.HTTPResponseCodes.serviceUnavailable.rawValue, + OptimizeConstants.HTTPResponseCodes.gatewayTimeout.rawValue] + /// Array containing the schema strings for the proposition items supported by the SDK, sent in the personalization query request. static let supportedSchemas = [ // Target schemas @@ -80,20 +94,9 @@ public class Optimize: NSObject, Extension { } public func onRegistered() { - registerListener(type: EventType.optimize, source: EventSource.requestContent) { event in - if event.isUpdateEvent { - self.processUpdatePropositions(event: event) - } else if event.isGetEvent { - // Queue the get propositions event in internal events queue to ensure any prior update requests are completed - // before it is processed. - self.eventsQueue.add(event) - } else if event.isTrackEvent { - self.processTrackPropositions(event: event) - } else { - Log.warning(label: OptimizeConstants.LOG_TAG, "Ignoring event! Cannot determine the type of request event.") - return - } - } + registerListener(type: EventType.optimize, + source: EventSource.requestContent, + listener: processOptimizeRequestContent(event:)) registerListener(type: EventType.edge, source: OptimizeConstants.EventSource.EDGE_PERSONALIZATION_DECISIONS, @@ -139,6 +142,43 @@ public class Optimize: NSObject, Extension { return true } + /// Processes the propositions request event, dispatched with type `EventType.optimize` and source `EventSource.requestContent`. + /// + /// It processes events based on the "requesttype" in the event data + /// - Parameter event: propositions request event + private func processOptimizeRequestContent(event: Event) { + if event.isUpdateEvent { + processUpdatePropositions(event: event) + } else if event.isGetEvent { + guard let eventDecisionScopes: [DecisionScope] = event.getTypedData(for: OptimizeConstants.EventDataKeys.DECISION_SCOPES), + !eventDecisionScopes.isEmpty + else { + Log.debug(label: OptimizeConstants.LOG_TAG, "Decision scopes, in event data, is either not present or empty.") + let aepOptimizeError = AEPOptimizeError.createAEPOptimizInvalidRequestError() + dispatch(event: event.createErrorResponseEvent(aepOptimizeError)) + return + } + /// Fetch propositions and check if all of the decision scopes are present in the cache + let fetchedPropositions = eventDecisionScopes.filter { self.cachedPropositions.keys.contains($0) } + /// Check if the decision scopes are currently in progress in `updateRequestEventIdsInProgress` + let scopesInProgress = eventDecisionScopes.filter { scope in + updateRequestEventIdsInProgress.values.flatMap { $0 }.contains(scope) + } + if eventDecisionScopes.count == fetchedPropositions.count && scopesInProgress.isEmpty { + processGetPropositions(event: event) + } else { + /// Not all decision scopes are present in the cache or requested scopes are currently in progress, adding it to the event queue + eventsQueue.add(event) + Log.trace(label: OptimizeConstants.LOG_TAG, "Decision scopes are either not present or currently in progress.") + } + } else if event.isTrackEvent { + processTrackPropositions(event: event) + } else { + Log.warning(label: OptimizeConstants.LOG_TAG, "Ignoring event! Cannot determine the type of request event.") + return + } + } + // MARK: Event Listeners /// Processes the update propositions request event, dispatched with type `EventType.optimize` and source `EventSource.requestContent`. @@ -216,19 +256,34 @@ public class Optimize: NSObject, Extension { // add the Edge event to update propositions in the events queue. eventsQueue.add(edgeEvent) - // Increase timeout to 5s to ensure edge requests have enough time to complete. - MobileCore.dispatch(event: edgeEvent, timeout: 5) { responseEvent in + // Increase timeout to 10s to ensure edge requests have enough time to complete. + MobileCore.dispatch(event: edgeEvent, timeout: 10) { responseEvent in guard let responseEvent = responseEvent, let requestEventId = responseEvent.requestEventId else { - // response event failed or timed out, remove this event's ID from the requested event IDs dictionary and kick-off queue. + // response event failed or timed out, remove this event's ID from the requested event IDs dictionary, dispatch an error response event and kick-off queue. self.updateRequestEventIdsInProgress.removeValue(forKey: edgeEvent.id.uuidString) self.propositionsInProgress.removeAll() - + let timeoutError = AEPOptimizeError.createAEPOptimizeTimeoutError() + self.dispatch(event: event.createErrorResponseEvent(timeoutError)) self.eventsQueue.start() return } + // Error response received for Edge request event UUID (if any) + let edgeError = self.updateRequestEventIdsErrors[requestEventId] + + // response event to provide success callback to updateProposition public api + let responseEventToSend = event.createResponseEvent( + name: OptimizeConstants.EventNames.OPTIMIZE_RESPONSE, + type: EventType.optimize, + source: EventSource.responseContent, + data: [ + OptimizeConstants.EventDataKeys.PROPOSITIONS: self.propositionsInProgress, + OptimizeConstants.EventDataKeys.RESPONSE_ERROR: edgeError as Any + ] + ) + self.dispatch(event: responseEventToSend) let updateCompleteEvent = responseEvent.createChainedEvent(name: OptimizeConstants.EventNames.OPTIMIZE_UPDATE_COMPLETE, type: EventType.optimize, @@ -301,7 +356,6 @@ public class Optimize: NSObject, Extension { """ Ignoring Edge event, either handle type is not personalization:decisions, or the response isn't intended for this extension. """) - propositionsInProgress.removeAll() return } @@ -345,16 +399,43 @@ public class Optimize: NSObject, Extension { /// It logs error related information specifying error type along with a detailed message. /// - Parameter event: Edge error response event. private func processEdgeErrorResponse(event: Event) { + guard + event.isEdgeErrorResponseEvent, + let requestEventId = event.requestEventId, + updateRequestEventIdsInProgress.contains(where: { $0.key == requestEventId }) + else { + Log.debug(label: OptimizeConstants.LOG_TAG, + """ + Ignoring Edge event, either handle type is not errorResponseContent, or the response isn't intended for this extension. + """) + return + } let errorType = event.data?[OptimizeConstants.Edge.ErrorKeys.TYPE] as? String + let errorStatus = event.data?[OptimizeConstants.Edge.ErrorKeys.STATUS] as? Int + let errorTitle = event.data?[OptimizeConstants.Edge.ErrorKeys.TITLE] as? String let errorDetail = event.data?[OptimizeConstants.Edge.ErrorKeys.DETAIL] as? String + let errorReport = event.data?[OptimizeConstants.Edge.ErrorKeys.REPORT] as? [String: Any] let errorString = """ Decisioning Service error, type: \(errorType ?? OptimizeConstants.ERROR_UNKNOWN), \ - detail: \(errorDetail ?? OptimizeConstants.ERROR_UNKNOWN)" + status: \(errorStatus ?? OptimizeConstants.UNKNOWN_STATUS), \ + title: \(errorTitle ?? OptimizeConstants.ERROR_UNKNOWN), \ + detail: \(errorDetail ?? OptimizeConstants.ERROR_UNKNOWN), \ + report: \(errorReport ?? [:])" """ Log.warning(label: OptimizeConstants.LOG_TAG, errorString) + + if let errorStatus = errorStatus, !shouldSuppressRecoverableError(status: errorStatus) { + let aepOptimizeError = AEPOptimizeError(type: errorType, status: errorStatus, title: errorTitle, detail: errorDetail, report: errorReport) + guard let edgeEventRequestId = event.requestEventId else { + Log.debug(label: OptimizeConstants.LOG_TAG, "No valid edge event request ID found for error response event.") + return + } + // store the error response as an AEPOptimizeError in error dictionary per edge request + updateRequestEventIdsErrors[edgeEventRequestId] = aepOptimizeError + } } /// Processes the get propositions request event, dispatched with type `EventType.optimize` and source `EventSource.requestContent`. @@ -366,7 +447,8 @@ public class Optimize: NSObject, Extension { !decisionScopes.isEmpty else { Log.debug(label: OptimizeConstants.LOG_TAG, "Decision scopes, in event data, is either not present or empty.") - dispatch(event: event.createErrorResponseEvent(AEPError.invalidRequest)) + let aepOptimizeError = AEPOptimizeError.createAEPOptimizInvalidRequestError() + dispatch(event: event.createErrorResponseEvent(aepOptimizeError)) return } @@ -429,6 +511,14 @@ public class Optimize: NSObject, Extension { cachedPropositions.removeAll() } + /// Helper function to check if edge error response received should be suppressed as it is already being retried on Edge + private func shouldSuppressRecoverableError(status: Int) -> Bool { + if recoverableNetworkErrorCodes.contains(status) { + return true + } + return false + } + #if DEBUG /// For testing purposes only func setUpdateRequestEventIdsInProgress(_ eventId: String, expectedScopes: [DecisionScope]) { diff --git a/Sources/AEPOptimize/OptimizeConstants.swift b/Sources/AEPOptimize/OptimizeConstants.swift index 0968d2a..73d05d6 100644 --- a/Sources/AEPOptimize/OptimizeConstants.swift +++ b/Sources/AEPOptimize/OptimizeConstants.swift @@ -13,7 +13,7 @@ enum OptimizeConstants { static let EXTENSION_NAME = "com.adobe.optimize" static let FRIENDLY_NAME = "Optimize" - static let EXTENSION_VERSION = "5.0.1" + static let EXTENSION_VERSION = "5.1.0" static let LOG_TAG = FRIENDLY_NAME static let DECISION_SCOPE_NAME = "name" @@ -26,6 +26,7 @@ enum OptimizeConstants { static let XDM_ITEM_COUNT = "xdm:itemCount" static let ERROR_UNKNOWN = "unknown" + static let UNKNOWN_STATUS = 0 enum EventNames { static let UPDATE_PROPOSITIONS_REQUEST = "Optimize Update Propositions Request" @@ -69,7 +70,10 @@ enum OptimizeConstants { static let PAYLOAD = "payload" enum ErrorKeys { static let TYPE = "type" + static let STATUS = "status" + static let TITLE = "title" static let DETAIL = "detail" + static let REPORT = "report" } } @@ -119,4 +123,31 @@ enum OptimizeConstants { static let SCHEMA_OFFER_IMAGE = "https://ns.adobe.com/experience/offer-management/content-component-imagelink" static let SCHEMA_OFFER_TEXT = "https://ns.adobe.com/experience/offer-management/content-component-text" } + + enum ErrorData { + enum Timeout { + static let STATUS = 408 + static let TITLE = "Request Timeout" + static let DETAIL = "Update/Get proposition request resulted in a timeout." + } + + enum InvalidRequest { + static let STATUS = 400 + static let TITLE = "Invalid Request" + static let DETAIL = "Decision scopes, in event data, is either not present or empty." + } + } + + enum HTTPResponseCodes: Int { + case success = 200 + case noContent = 204 + case multiStatus = 207 + case invalidRequest = 400 + case clientTimeout = 408 + case tooManyRequests = 429 + case internalServerError = 500 + case badGateway = 502 + case serviceUnavailable = 503 + case gatewayTimeout = 504 + } } diff --git a/Sources/AEPOptimize/OptimizeError.swift b/Sources/AEPOptimize/OptimizeError.swift new file mode 100644 index 0000000..702b101 --- /dev/null +++ b/Sources/AEPOptimize/OptimizeError.swift @@ -0,0 +1,85 @@ +// Delete this line +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import Foundation + +/// AEPOptimizeError class used to create AEPOptimizeError from error details received from Experience Edge. +@objc(AEPOptimizeError) +public class AEPOptimizeError: NSObject, Error { + typealias HTTPResponseCodes = OptimizeConstants.HTTPResponseCodes + public let type: String? + public let status: Int? + public let title: String? + public let detail: String? + public let report: [String: Any]? + public var aepError = AEPError.unexpected + + private let serverErrors = [ + HTTPResponseCodes.tooManyRequests.rawValue, + HTTPResponseCodes.internalServerError.rawValue, + HTTPResponseCodes.serviceUnavailable.rawValue + ] + + private let networkError = [ + HTTPResponseCodes.badGateway.rawValue, + HTTPResponseCodes.gatewayTimeout.rawValue + ] + + public init(type: String?, status: Int?, title: String?, detail: String?, report: [String: Any]?, aepError: AEPError? = nil) { + self.type = type + self.status = status + self.title = title + self.detail = detail + self.report = report + if let aepError { + self.aepError = aepError + } else { + // map edge error response to AEPError on the basis of status (if received) + guard let status else { + return + } + if status == HTTPResponseCodes.clientTimeout.rawValue { + self.aepError = .callbackTimeout + } else if serverErrors.contains(status) { + self.aepError = .serverError + } else if networkError.contains(status) { + self.aepError = .networkError + } else if (400 ... 499).contains(status) { + self.aepError = .invalidRequest + } + } + } + + static func createAEPOptimizeTimeoutError() -> AEPOptimizeError { + AEPOptimizeError( + type: nil, + status: OptimizeConstants.ErrorData.Timeout.STATUS, + title: OptimizeConstants.ErrorData.Timeout.TITLE, + detail: OptimizeConstants.ErrorData.Timeout.DETAIL, + report: nil, + aepError: AEPError.callbackTimeout + ) + } + + static func createAEPOptimizInvalidRequestError() -> AEPOptimizeError { + AEPOptimizeError( + type: nil, + status: OptimizeConstants.ErrorData.InvalidRequest.STATUS, + title: OptimizeConstants.ErrorData.InvalidRequest.TITLE, + detail: OptimizeConstants.ErrorData.InvalidRequest.DETAIL, + report: nil, + aepError: AEPError.invalidRequest + ) + } +} diff --git a/TestApps/AEPOptimizeDemoObjC/ViewController.m b/TestApps/AEPOptimizeDemoObjC/ViewController.m index 7e1e442..3e28c41 100644 --- a/TestApps/AEPOptimizeDemoObjC/ViewController.m +++ b/TestApps/AEPOptimizeDemoObjC/ViewController.m @@ -31,33 +31,33 @@ - (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view. - [AEPMobileOptimize onPropositionsUpdate:^(NSDictionary *propositionsDict) { + [AEPMobileOptimize onPropositionsUpdate:^(NSDictionary *propositionsDict) { - AEPProposition* textProposition = propositionsDict[textDecisionScope]; + AEPOptimizeProposition* textProposition = propositionsDict[textDecisionScope]; NSLog(@"Callback Logging %ld Text Offer(s)", [textProposition.offers count]); for(AEPOffer* offer in textProposition.offers) { NSLog(@"Callback Text Offer Content:%@", offer.content); } - AEPProposition* imageProposition = propositionsDict[imageDecisionScope]; + AEPOptimizeProposition* imageProposition = propositionsDict[imageDecisionScope]; NSLog(@"Callback Logging %ld Image Offer(s)", [imageProposition.offers count]); for(AEPOffer* offer in imageProposition.offers) { NSLog(@"Callback Image Offer Content:%@", offer.content); } - AEPProposition* htmlProposition = propositionsDict[htmlDecisionScope]; + AEPOptimizeProposition* htmlProposition = propositionsDict[htmlDecisionScope]; NSLog(@"Callback Logging %ld Html Offer(s)", [htmlProposition.offers count]); for(AEPOffer* offer in htmlProposition.offers) { NSLog(@"Callback Html Offer Content:%@", offer.content); } - AEPProposition* jsonProposition = propositionsDict[jsonDecisionScope]; + AEPOptimizeProposition* jsonProposition = propositionsDict[jsonDecisionScope]; NSLog(@"Callback Logging %ld Json Offer(s)", [jsonProposition.offers count]); for(AEPOffer* offer in jsonProposition.offers) { NSLog(@"Callback Json Offer Content:%@", offer.content); } - AEPProposition* targetProposition = propositionsDict[targetDecisionScope]; + AEPOptimizeProposition* targetProposition = propositionsDict[targetDecisionScope]; NSLog(@"Callback Logging %ld Target Offer(s)", [targetProposition.offers count]); for(AEPOffer* offer in targetProposition.offers) { NSLog(@"Callback Target Offer Content:%@", offer.content); @@ -96,37 +96,37 @@ - (IBAction)getPropositions:(id)sender { htmlDecisionScope, jsonDecisionScope, targetDecisionScope - ] completion:^(NSDictionary* propositionsDict, NSError* error) { + ] completion:^(NSDictionary* propositionsDict, NSError* error) { if (error != nil) { NSLog(@"Get propositions failed with error: %@", [error localizedDescription]); return; } - AEPProposition* textProposition = propositionsDict[textDecisionScope]; + AEPOptimizeProposition* textProposition = propositionsDict[textDecisionScope]; NSLog(@"Logging %ld Text Offer(s)", [textProposition.offers count]); for(AEPOffer* offer in textProposition.offers) { NSLog(@"Text Offer Content:%@", offer.content); } - AEPProposition* imageProposition = propositionsDict[imageDecisionScope]; + AEPOptimizeProposition* imageProposition = propositionsDict[imageDecisionScope]; NSLog(@"Logging %ld Image Offer(s)", [imageProposition.offers count]); for(AEPOffer* offer in imageProposition.offers) { NSLog(@"Image Offer Content:%@", offer.content); } - AEPProposition* htmlProposition = propositionsDict[htmlDecisionScope]; + AEPOptimizeProposition* htmlProposition = propositionsDict[htmlDecisionScope]; NSLog(@"Logging %ld Html Offer(s)", [htmlProposition.offers count]); for(AEPOffer* offer in htmlProposition.offers) { NSLog(@"Html Offer Content:%@", offer.content); } - AEPProposition* jsonProposition = propositionsDict[jsonDecisionScope]; + AEPOptimizeProposition* jsonProposition = propositionsDict[jsonDecisionScope]; NSLog(@"Logging %ld Json Offer(s)", [jsonProposition.offers count]); for(AEPOffer* offer in jsonProposition.offers) { NSLog(@"Json Offer Content:%@", offer.content); } - AEPProposition* targetProposition = propositionsDict[targetDecisionScope]; + AEPOptimizeProposition* targetProposition = propositionsDict[targetDecisionScope]; NSLog(@"Logging %ld Target Offer(s)", [targetProposition.offers count]); for(AEPOffer* offer in targetProposition.offers) { NSLog(@"Target Offer Content:%@", offer.content); diff --git a/TestApps/AEPOptimizeDemoSwiftUI/OffersView.swift b/TestApps/AEPOptimizeDemoSwiftUI/OffersView.swift index b5b769f..aa345d4 100644 --- a/TestApps/AEPOptimizeDemoSwiftUI/OffersView.swift +++ b/TestApps/AEPOptimizeDemoSwiftUI/OffersView.swift @@ -160,8 +160,20 @@ struct OffersView: View { htmlDecisionScope, jsonDecisionScope, targetScope - ], withXdm: ["xdmKey": "1234"], - andData: data) + ], withXdm: ["xdmKey": "1234"], andData: data) { data, error in + if let error = error as? AEPOptimizeError { + errorAlert = true + if let errorStatus = error.status { + errorMessage = (error.title ?? "Unexpected Error") + " : " + String(errorStatus) + }else{ + errorMessage = error.title ?? "Unexpected Error" + } + } + } + + } + .alert(isPresented: $errorAlert) { + Alert(title: Text("Error: Update Propositions"), message: Text(errorMessage), dismissButton: .default(Text("OK"))) } CustomButtonView(buttonTitle: "Get Propositions") { diff --git a/Tests/AEPOptimizeTests/FunctionalTests/OptimizeFunctionalTests.swift b/Tests/AEPOptimizeTests/FunctionalTests/OptimizeFunctionalTests.swift index 2c0412b..f94724c 100644 --- a/Tests/AEPOptimizeTests/FunctionalTests/OptimizeFunctionalTests.swift +++ b/Tests/AEPOptimizeTests/FunctionalTests/OptimizeFunctionalTests.swift @@ -847,6 +847,325 @@ class OptimizeFunctionalTests: XCTestCase { XCTAssertEqual("true", proposition?.offers[0].characteristics?["testing"]) } + func testGetPropositions_dispatchPropositionFromCacheBeforeNextUpdate() throws { + /// Setup + let decisionScopeA = DecisionScope(name: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpdml0eUlkIjoic2NvcGUtYSIsInBsYWNlbWVudElkIjoic2NvcGUtYV9wbGFjZW1lbnQifQ.KW1HKVJHTTdmUkJZUmM5UEhNdURtOGdT") + + let decisionScopeB = DecisionScope(name: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpdml0eUlkIjoic2NvcGUtYiIsInBsYWNlbWVudElkIjoic2NvcGUtYl9wbGFjZW1lbnQifQ.QzNxT1dBZ1Z1M0Z5dW84SjdKak1nY2c1") + + let propositionA = """ + { + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "scope": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpdml0eUlkIjoic2NvcGUtYSIsInBsYWNlbWVudElkIjoic2NvcGUtYV9wbGFjZW1lbnQifQ.KW1HKVJHTTdmUkJZUmM5UEhNdURtOGdT" + } + """.data(using: .utf8)! + + guard let propositionForScopeA = try? JSONDecoder().decode(OptimizeProposition.self, from: propositionA) else { + XCTFail("Propositions should be valid.") + return + } + + /// Cache decisionScopeA + optimize.cachedPropositions = [ + decisionScopeA: propositionForScopeA + ] + + let getEvent = Event( + name: "Get Propositions Request", + type: "com.adobe.eventType.optimize", + source: "com.adobe.eventSource.requestContent", + data: [ + "requesttype": "getpropositions", + "decisionscopes": [ + ["name": decisionScopeA.name] + ] + ] + ) + + let updateEvent = Event( + name: "Update Propositions Request", + type: "com.adobe.eventType.optimize", + source: "com.adobe.eventSource.requestContent", + data: [ + "requesttype": "updatepropositions", + "decisionscopes": [ + ["name": decisionScopeB.name] + ] + ] + ) + + mockRuntime.simulateSharedState(for: ("com.adobe.module.configuration", updateEvent), + data: ([ + "edge.configId": "ffffffff-ffff-ffff-ffff-ffffffffffff"] as [String: Any], .set)) + /// Dispatch the Update event + mockRuntime.simulateComingEvents(updateEvent) + + let updateEventIdsInProgress = optimize.getUpdateRequestEventIdsInProgress() + XCTAssertEqual(1, updateEventIdsInProgress.count) + + let optimizeContentComplete = Event( + name: "Optimize Update Propositions Complete", + type: "com.adobe.eventType.optimize", + source: "com.adobe.eventSource.contentComplete", + data: [ + "completedUpdateRequestForEventId": updateEvent.id.uuidString + ] + ) + + let expectationGet = XCTestExpectation(description: "Get event should be processed first.") + + var dispatchedEvents = [Event]() + + mockRuntime.onEventDispatch = { event in + dispatchedEvents.append(event) + expectationGet.fulfill() + } + + /// Dispatch the events + mockRuntime.simulateComingEvents(getEvent) + mockRuntime.simulateComingEvents(optimizeContentComplete) + + /// Verify + wait(for: [expectationGet], timeout: 5) + + XCTAssertEqual(mockRuntime.firstEvent?.type, "com.adobe.eventType.optimize") + XCTAssertEqual(mockRuntime.firstEvent?.source, "com.adobe.eventSource.responseContent") + + guard let propositionsDictionary: [DecisionScope: OptimizeProposition] = mockRuntime.firstEvent?.getTypedData(for: "propositions") else { + XCTFail("Propositions dictionary should be valid.") + return + } + + XCTAssertEqual(propositionsDictionary[decisionScopeA]?.id, optimize.cachedPropositions[decisionScopeA]?.id) + XCTAssertEqual("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", propositionsDictionary[decisionScopeA]?.id) + XCTAssertEqual("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpdml0eUlkIjoic2NvcGUtYSIsInBsYWNlbWVudElkIjoic2NvcGUtYV9wbGFjZW1lbnQifQ.KW1HKVJHTTdmUkJZUmM5UEhNdURtOGdT", propositionsDictionary[decisionScopeA]?.scope) + } + + func testGetPropositions_ScopesFromEventIsUpdateInProgress() { + // Setup + let decisionScopeA = DecisionScope(name: "eyJhY3Rpdml0eUlkIjoieGNvcmU6b2ZmZXItYWN0aXZpdHk6MTExMTExMTExMTExMTExMSIsInBsYWNlbWVudElkIjoieGNvcmU6b2ZmZXItcGxhY2VtZW50OjExMTExMTExMTExMTExMTEifQ==") + + let cachedPropositionJSON = """ + { + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "scope": "eyJhY3Rpdml0eUlkIjoieGNvcmU6b2ZmZXItYWN0aXZpdHk6MTExMTExMTExMTExMTExMSIsInBsYWNlbWVudElkIjoieGNvcmU6b2ZmZXItcGxhY2VtZW50OjExMTExMTExMTExMTExMTEifQ==" + } + """.data(using: .utf8)! + + let updatedPropositionJSON = """ + { + "id": "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + "scope": "eyJhY3Rpdml0eUlkIjoieGNvcmU6b2ZmZXItYWN0aXZpdHk6MTExMTExMTExMTExMTExMSIsInBsYWNlbWVudElkIjoieGNvcmU6b2ZmZXItcGxhY2VtZW50OjExMTExMTExMTExMTExMTEifQ==" + } + """.data(using: .utf8)! + + guard let cachedPropositionForScopeA = try? JSONDecoder().decode(OptimizeProposition.self, from: cachedPropositionJSON), + let updatedPropositionForScopeA = try? JSONDecoder().decode(OptimizeProposition.self, from: updatedPropositionJSON) else { + XCTFail("Propositions should be valid.") + return + } + + /// Same Decision Scope is already cached with old propositions + optimize.cachedPropositions = [ + decisionScopeA: cachedPropositionForScopeA + ] + + let updateEvent = Event( + name: "Optimize Update Propositions Request", + type: "com.adobe.eventType.optimize", + source: "com.adobe.eventSource.requestContent", + data: [ + "requesttype": "updatepropositions", + "decisionscopes": [ + ["name": decisionScopeA.name] + ] + ] + ) + + let getEvent = Event( + name: "Optimize Get Propositions Request", + type: "com.adobe.eventType.optimize", + source: "com.adobe.eventSource.requestContent", + data: [ + "requesttype": "getpropositions", + "decisionscopes": [ + ["name": decisionScopeA.name] + ] + ] + ) + + mockRuntime.simulateSharedState(for: ("com.adobe.module.configuration", updateEvent), + data: ([ + "edge.configId": "ffffffff-ffff-ffff-ffff-ffffffffffff"] as [String: Any], .set)) + + /// Simulating update & get events + mockRuntime.simulateComingEvents(updateEvent) + mockRuntime.simulateComingEvents(getEvent) + + XCTAssertTrue(mockRuntime.dispatchedEvents.isEmpty) + + let optimizeContentComplete = Event( + name: "Optimize Update Propositions Complete", + type: "com.adobe.eventType.optimize", + source: "com.adobe.eventSource.contentComplete", + data: [ + "completedUpdateRequestForEventId": updateEvent.id.uuidString, + "propositions" : [updatedPropositionJSON] + ] + ) + + mockRuntime.simulateComingEvents(optimizeContentComplete) + optimize.cachedPropositions[decisionScopeA] = updatedPropositionForScopeA + + /// After the update is complete, the get event should now dispatch + let expectation = XCTestExpectation(description: "Get propositions request should now dispatch response event after update completion.") + + mockRuntime.onEventDispatch = { event in + if event.responseID == getEvent.id { + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 12) + XCTAssertEqual(mockRuntime.dispatchedEvents.count, 2) + + // first event will be a timeout error response event as in functional test we don't recieve response from edge + let firstEvent = mockRuntime.firstEvent + XCTAssertEqual(firstEvent?.type, "com.adobe.eventType.optimize") + XCTAssertEqual(firstEvent?.source, "com.adobe.eventSource.responseContent") + XCTAssertNotNil(firstEvent?.data?[OptimizeConstants.EventDataKeys.RESPONSE_ERROR]) + XCTAssertNil(firstEvent?.data?[OptimizeConstants.EventDataKeys.DECISION_SCOPES]) + + let dispatchedEvent = mockRuntime.secondEvent + XCTAssertEqual(dispatchedEvent?.type, "com.adobe.eventType.optimize") + XCTAssertEqual(dispatchedEvent?.source, "com.adobe.eventSource.responseContent") + + /// Validate that the Get proposition response contains the updated proposition + guard let propositionsDictionary: [DecisionScope: OptimizeProposition] = dispatchedEvent?.getTypedData(for: "propositions") else { + XCTFail("Propositions dictionary should be valid.") + return + } + XCTAssertEqual(propositionsDictionary[decisionScopeA]?.id, updatedPropositionForScopeA.id) + XCTAssertEqual("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", propositionsDictionary[decisionScopeA]?.id) + XCTAssertEqual("eyJhY3Rpdml0eUlkIjoieGNvcmU6b2ZmZXItYWN0aXZpdHk6MTExMTExMTExMTExMTExMSIsInBsYWNlbWVudElkIjoieGNvcmU6b2ZmZXItcGxhY2VtZW50OjExMTExMTExMTExMTExMTEifQ==", propositionsDictionary[decisionScopeA]?.scope) + } + + func testGetPropositions_fewDecisionScopesNotInCacheAndGetToBeQueued() { + /// Setup + let decisionScopeA = DecisionScope(name: "eyJhY3Rpdml0eUlkIjoieGNvcmU6b2ZmZXItYWN0aXZpdHk6MTExMTExMTExMTExMTExMSIsInBsYWNlbWVudElkIjoieGNvcmU6b2ZmZXItcGxhY2VtZW50OjExMTExMTExMTExMTExMTEifQ==") + let decisionScopeB = DecisionScope(name: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpdml0eUlkIjoic2NvcGUtYiIsInBsYWNlbWVudElkIjoic2NvcGUtYl9wbGFjZW1lbnQifQ.QzNxT1dBZ1Z1M0Z5dW84SjdKak1nY2c1") + + let propositionA = """ + { + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "scope": "eyJhY3Rpdml0eUlkIjoieGNvcmU6b2ZmZXItYWN0aXZpdHk6MTExMTExMTExMTExMTExMSIsInBsYWNlbWVudElkIjoieGNvcmU6b2ZmZXItcGxhY2VtZW50OjExMTExMTExMTExMTExMTEifQ==" + } + """.data(using: .utf8)! + + let propositionB = """ + { + "id": "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + "scope": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpdml0eUlkIjoic2NvcGUtYiIsInBsYWNlbWVudElkIjoic2NvcGUtYl9wbGFjZW1lbnQifQ.QzNxT1dBZ1Z1M0Z5dW84SjdKak1nY2c1" + } + """.data(using: .utf8)! + + guard let cachedPropositionForScopeA = try? JSONDecoder().decode(OptimizeProposition.self, from: propositionA), + let cachedPropositionForScopeB = try? JSONDecoder().decode(OptimizeProposition.self, from: propositionB) else { + XCTFail("Propositions should be valid.") + return + } + + /// decisionScopeA is already cached. + optimize.cachedPropositions = [ + decisionScopeA: cachedPropositionForScopeA + ] + + /// Creating a get event with a decisionScopeB that is currently not present in the cache. + let getEvent = Event( + name: "Optimize Get Propositions Request", + type: "com.adobe.eventType.optimize", + source: "com.adobe.eventSource.requestContent", + data: [ + "requesttype": "getpropositions", + "decisionscopes": [ + ["name": decisionScopeA.name], + ["name": decisionScopeB.name] + ] + ] + ) + + /// Update event with decisionScopeB. + let updateEvent = Event( + name: "Optimize Update Propositions Request", + type: "com.adobe.eventType.optimize", + source: "com.adobe.eventSource.requestContent", + data: [ + "requesttype": "updatepropositions", + "decisionscopes": [ + ["name": decisionScopeB.name] + ] + ] + ) + + mockRuntime.simulateSharedState(for: ("com.adobe.module.configuration", updateEvent), + data: ([ + "edge.configId": "ffffffff-ffff-ffff-ffff-ffffffffffff"] as [String: Any], .set)) + + /// Dispatching the update event. + mockRuntime.simulateComingEvents(updateEvent) + optimize.setUpdateRequestEventIdsInProgress(updateEvent.id.uuidString, expectedScopes: [decisionScopeB]) + optimize.setPropositionsInProgress([decisionScopeB : cachedPropositionForScopeB]) + + let optimizeContentComplete = Event( + name: "Optimize Update Propositions Complete", + type: "com.adobe.eventType.optimize", + source: "com.adobe.eventSource.contentComplete", + data: [ + "completedUpdateRequestForEventId": updateEvent.id.uuidString, + "propositions" : [propositionB] + ] + ) + + let expectationGet = XCTestExpectation(description: "Get event should be queued.") + mockRuntime.onEventDispatch = { event in + if event.responseID == getEvent.id { + expectationGet.fulfill() + } + } + + /// Dispatch the get event Immediately. + mockRuntime.simulateComingEvents(getEvent) + + /// Dispatching the proposition complete event after a delay of 1 second to simulate a real use case. + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {[weak self] in + self?.mockRuntime.simulateComingEvents(optimizeContentComplete) + }) + + wait(for: [expectationGet], timeout: 12) + + // first event will be a timeout error response event as in functional test we don't recieve response from edge + let firstEvent = mockRuntime.firstEvent + XCTAssertEqual(firstEvent?.type, "com.adobe.eventType.optimize") + XCTAssertEqual(firstEvent?.source, "com.adobe.eventSource.responseContent") + XCTAssertNotNil(firstEvent?.data?[OptimizeConstants.EventDataKeys.RESPONSE_ERROR]) + XCTAssertNil(firstEvent?.data?[OptimizeConstants.EventDataKeys.DECISION_SCOPES]) + + /// Verify that the get proposition event was queued & is the last event to be executed. + XCTAssertEqual(mockRuntime.secondEvent?.type, "com.adobe.eventType.optimize") + XCTAssertEqual(mockRuntime.secondEvent?.source, "com.adobe.eventSource.responseContent") + + guard let propositionsDictionary: [DecisionScope: OptimizeProposition] = mockRuntime.secondEvent?.getTypedData(for: "propositions") else { + XCTFail("Propositions dictionary should be valid.") + return + } + + /// Verify that the proposition for decisionScopeB is present in the reponse event as well as in the cache. + XCTAssertTrue(propositionsDictionary.keys.contains(decisionScopeB)) + XCTAssertEqual("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", propositionsDictionary[decisionScopeB]?.id) + XCTAssertEqual("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", optimize.cachedPropositions[decisionScopeB]?.id) + XCTAssertEqual(propositionsDictionary[decisionScopeB]?.id, optimize.cachedPropositions[decisionScopeB]?.id) + } + func testGetPropositions_notAllDecisionScopesInCache() { // setup let propositionsData = @@ -1094,9 +1413,10 @@ class OptimizeFunctionalTests: XCTestCase { XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) let dispatchedEvent = mockRuntime.dispatchedEvents.first + let errorData = dispatchedEvent?.data?["responseerror"] as? AEPOptimizeError XCTAssertEqual("com.adobe.eventType.optimize", dispatchedEvent?.type) XCTAssertEqual("com.adobe.eventSource.responseContent", dispatchedEvent?.source) - XCTAssertEqual(AEPError.invalidRequest, AEPError(rawValue: dispatchedEvent?.data?["responseerror"] as? Int ?? 1000)) + XCTAssertEqual(AEPError.invalidRequest, errorData?.aepError) XCTAssertNil(dispatchedEvent?.data?["propositions"]) } diff --git a/Tests/AEPOptimizeTests/IntegrationTests/OptimizeIntegrationTests.swift b/Tests/AEPOptimizeTests/IntegrationTests/OptimizeIntegrationTests.swift index cd76df9..39608bb 100644 --- a/Tests/AEPOptimizeTests/IntegrationTests/OptimizeIntegrationTests.swift +++ b/Tests/AEPOptimizeTests/IntegrationTests/OptimizeIntegrationTests.swift @@ -54,6 +54,70 @@ class OptimizeIntegrationTests: XCTestCase { } wait(for: [initExpectation], timeout: 1) } + + func testUpdatePropositions_timeoutError() { + // setup + let timeoutResponse = HTTPURLResponse(url: URL(string: "https://edge.adobedc.net/ee/v1/interact?configId=configId&requestId=requestId")!, statusCode: 408, httpVersion: nil, headerFields: nil) + let responseString = """ + {\ + "requestId":"FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF",\ + "handle":[],\ + "errors":[\ + {\ + "type":"EXEG-0201-408",\ + "status":408,\ + "title":"Request timed out. Please try again."\ + }\ + ]\ + } + """ + + // mock edge response + let requestExpectation = XCTestExpectation(description: "Request for mock service response.") + let mockNetworkService = TestableNetworkService() + ServiceProvider.shared.networkService = mockNetworkService + mockNetworkService.mock { request in + if request.url.absoluteString.contains("edge.adobedc.net/ee/v1/interact?configId=configId") { + requestExpectation.fulfill() + return (data: responseString.data(using: .utf8), response: timeoutResponse, error: nil) + } + return (data: nil, response: timeoutResponse, error: nil) + } + + // init extensions + initExtensionsAndWait() + + // update configuration + MobileCore.updateConfigurationWith(configDict: [ + "experienceCloud.org": "orgid", + "experienceCloud.server": "test.com", + "global.privacy": "optedin", + "edge.configId": "configId"]) + + let decisionScope = DecisionScope(activityId: "xcore:offer-activity:1111111111111111", + placementId: "xcore:offer-placement:1111111111111111") + + let exp = expectation(description: "The Update Proposition should result in a time out") + + // test + Optimize.updatePropositions(for: [decisionScope], withXdm: nil) {propositions, error in + // verify + guard let error = error as? AEPOptimizeError else { + XCTFail("Type mismatch in error received for Update Propositions") + return + } + XCTAssertNil(propositions) + XCTAssertNotNil(error) + XCTAssertTrue(error.status == 408) + XCTAssertTrue(error.aepError == .callbackTimeout) + XCTAssertTrue(error.title == "Request Timeout") + XCTAssertTrue(error.detail == "Update/Get proposition request resulted in a timeout.") + exp.fulfill() + } + + wait(for: [exp, requestExpectation], timeout: 12) + } + func testUpdatePropositions_validEdgeRequest() { // setup @@ -105,7 +169,11 @@ class OptimizeIntegrationTests: XCTestCase { placementId: "xcore:offer-placement:1111111111111111") // update propositions - Optimize.updatePropositions(for: [decisionScope], withXdm: nil) + Optimize.updatePropositions(for: [decisionScope], withXdm: nil){ (propositions,error) in + XCTAssertNotNil(propositions) + XCTAssertNil(error) + requestExpectation.fulfill() + } wait(for: [requestExpectation], timeout: 2) } @@ -389,6 +457,64 @@ class OptimizeIntegrationTests: XCTestCase { } wait(for: [retrieveExpectation], timeout: 2) } + + func testUpdateProposition_invalidEdgeResponse() { + //setup + let inValidResponse = HTTPURLResponse(url: URL(string: "https://edge.adobedc.net/ee/v1/interact?configId=configId&requestId=requestId")!, statusCode: 400, httpVersion: nil, headerFields: nil) + let responseString = """ + {\ + "requestId":"FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF",\ + "handle":[],\ + "errors":[\ + {\ + "type":"EXEG-0201-400",\ + "status":400,\ + "title":"Invalid Request",\ + "detail":"Request cannot be processed as few parameters are missing. Please check and try again later."\ + }\ + ]\ + } + """ + + let requestExpectation = XCTestExpectation(description: "updatePropositions should result in a valid personalization query request to the Edge network.") + let mockNetworkService = TestableNetworkService() + ServiceProvider.shared.networkService = mockNetworkService + mockNetworkService.mock { request in + if request.url.absoluteString.contains("edge.adobedc.net/ee/v1/interact?configId=configId") { + requestExpectation.fulfill() + return (data: responseString.data(using: .utf8), response: inValidResponse, error: nil) + } + return (data: nil, response: inValidResponse, error: nil) + } + + // init extensions + initExtensionsAndWait() + + // update configuration + MobileCore.updateConfigurationWith(configDict: [ + "experienceCloud.org": "orgid", + "experienceCloud.server": "test.com", + "global.privacy": "optedin", + "edge.configId": "configId"]) + + let decisionScope = DecisionScope(activityId: "xcore:offer-activity:1111111111111111", + placementId: "xcore:offer-placement:1111111111111111") + + let invalidResponseExpectation = XCTestExpectation(description: "updatePropositions should result in a inavlid resoponse from Edge Experience Network.") + + Optimize.updatePropositions(for: [decisionScope], withXdm: nil) { data, error in + if let error = error as? AEPOptimizeError { + XCTAssertNotNil(error) + XCTAssertTrue(error.status == 400) + XCTAssertTrue(error.aepError == .invalidRequest) + XCTAssertTrue(error.title == "Invalid Request") + XCTAssertTrue(error.detail == "Request cannot be processed as few parameters are missing. Please check and try again later.") + } + invalidResponseExpectation.fulfill() + } + + wait(for: [requestExpectation, invalidResponseExpectation], timeout: 12) + } func testGetPropositions_invalidEdgeResponse() { // setup diff --git a/Tests/AEPOptimizeTests/UnitTests/Event+OptimizeTests.swift b/Tests/AEPOptimizeTests/UnitTests/Event+OptimizeTests.swift index 9003ca4..0363c30 100644 --- a/Tests/AEPOptimizeTests/UnitTests/Event+OptimizeTests.swift +++ b/Tests/AEPOptimizeTests/UnitTests/Event+OptimizeTests.swift @@ -117,13 +117,18 @@ class Event_OptimizeTests: XCTestCase { source: "com.adobe.eventSource.requestContent", data: nil) - let errorResponseEvent = testEvent.createErrorResponseEvent(AEPError.invalidRequest) + let aepOptimizeError = AEPOptimizeError.createAEPOptimizInvalidRequestError() + let errorResponseEvent = testEvent.createErrorResponseEvent(aepOptimizeError) + + let errorData = errorResponseEvent.data?["responseerror"] as? AEPOptimizeError XCTAssertEqual("Optimize Response", errorResponseEvent.name) XCTAssertEqual("com.adobe.eventType.optimize", errorResponseEvent.type) XCTAssertEqual("com.adobe.eventSource.responseContent", errorResponseEvent.source) XCTAssertNotNil(errorResponseEvent.data) XCTAssertEqual(1, errorResponseEvent.data?.count) - XCTAssertEqual(6, errorResponseEvent.data?["responseerror"] as? Int) + XCTAssertEqual(400, errorData?.status) + XCTAssertEqual("Invalid Request", errorData?.title) + XCTAssertEqual(AEPError.invalidRequest, errorData?.aepError) } } diff --git a/Tests/AEPOptimizeTests/UnitTests/OfferTests.swift b/Tests/AEPOptimizeTests/UnitTests/OfferTests.swift index 88102cd..be21a11 100644 --- a/Tests/AEPOptimizeTests/UnitTests/OfferTests.swift +++ b/Tests/AEPOptimizeTests/UnitTests/OfferTests.swift @@ -161,8 +161,30 @@ class OfferTests: XCTestCase { }\ }\ } +""" + + private let OFFER_WITH_SCORE_AS_DOUBLE = +""" +{\ + "id": "xcore:personalized-offer:2222222222222222",\ + "etag": "7",\ + "score": 6.43,\ + "schema": "https://ns.adobe.com/experience/offer-management/content-component-text",\ + "data": {\ + "id": "xcore:personalized-offer:2222222222222222",\ + "format": "text/plain",\ + "content": "This is a plain text content!",\ + "language": [\ + "en-us"\ + ],\ + "characteristics": {\ + "mobile": "true"\ + }\ + }\ +} """ + private let OFFER_INVALID_NO_CONTENT = """ {\ @@ -365,6 +387,26 @@ class OfferTests: XCTestCase { XCTAssertEqual("true", offer.characteristics?["mobile"]) } + func testOffer_withScoreAsDouble() { + guard let offerData = OFFER_WITH_SCORE_AS_DOUBLE.data(using: .utf8), + let offer = try? JSONDecoder().decode(Offer.self, from: offerData) + else { + XCTFail("Offer should be valid.") + return + } + /// Assert the score is of type Double and has the correct value + XCTAssertTrue(type(of: offer.score) == Double.self, "Score should be a Double.") + XCTAssertEqual(6.43, offer.score, accuracy: 0.001, "Score value should be 6.43.") + + /// Other assertions to validate the remaining offer data + XCTAssertEqual("7", offer.etag) + XCTAssertEqual(OfferType.text, offer.type) + XCTAssertEqual("This is a plain text content!", offer.content) + XCTAssertEqual(1, offer.language?.count) + XCTAssertEqual("en-us", offer.language?[0]) + XCTAssertEqual("true", offer.characteristics?["mobile"]) + } + func testOffer_invalidNoContent() { guard let offerData = OFFER_INVALID_NO_CONTENT.data(using: .utf8) else { XCTFail("Offer json data should be valid.") diff --git a/Tests/AEPOptimizeTests/UnitTests/OptimizePublicAPITests.swift b/Tests/AEPOptimizeTests/UnitTests/OptimizePublicAPITests.swift index ea7e87c..6899a28 100644 --- a/Tests/AEPOptimizeTests/UnitTests/OptimizePublicAPITests.swift +++ b/Tests/AEPOptimizeTests/UnitTests/OptimizePublicAPITests.swift @@ -72,6 +72,31 @@ class OptimizePublicAPITests: XCTestCase { // verify wait(for: [expectation], timeout: 1) } + + func testUpdatePropositions_updateTimeout() { + // setup + let expectation = XCTestExpectation(description: "The Update proposition request should time out.") + expectation.assertForOverFulfill = true + + let decisionScope = DecisionScope(name: "eyJhY3Rpdml0eUlkIjoieGNvcmU6b2ZmZXItYWN0aXZpdHk6MTExMTExMTExMTExMTExMSIsInBsYWNlbWVudElkIjoieGNvcmU6b2ZmZXItcGxhY2VtZW50OjExMTExMTExMTExMTExMTEifQ==") + + // test + Optimize.updatePropositions(for: [decisionScope], withXdm: nil) { propositions, error in + guard let error = error as? AEPOptimizeError else { + XCTFail("Type mismatch in error received for Update Propositions") + return + } + XCTAssert(error.status == OptimizeConstants.ErrorData.Timeout.STATUS) + XCTAssert(error.aepError == .callbackTimeout) + XCTAssert(error.title == OptimizeConstants.ErrorData.Timeout.TITLE) + XCTAssert(error.detail == OptimizeConstants.ErrorData.Timeout.DETAIL) + XCTAssert(error.aepError == .callbackTimeout) + expectation.fulfill() + } + + // verify + wait(for: [expectation], timeout: 11) + } func testUpdatePropositions_validDecisionScopeWithXdmAndData() { // setup